Migrate /bootstrap.js endpoint to core (#92784)

* move bootstrap endpoint to core

* some initial cleanup

* hack around the 'try' auth status

* some UT

* more UT

* add try/catch around uISettings access

* add 'auth.isEnabled'

* remove dead files

* use `try` authent mode

* adapt UT

* revert themeTag movearound

* migrate apps route to core

* some cleanup

* nit

* add integration tests

* update generated doc

* add UT for /app route

* add etag IT

* nits

* remove auth.isEnabled API

* add tests on get_apm_config

* use string template instead of handlebars for bootstrap template

* improve plugin bundle tests

* update generated doc

* remove response.etag API

* update generated doc

* update generated doc

* update generated doc again

* extract getThemeTag

* add more unit tests
This commit is contained in:
Pierre Gayvallet 2021-03-10 10:35:26 +01:00 committed by GitHub
parent f9e28831c5
commit d0949121c8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
53 changed files with 1697 additions and 521 deletions

View file

@ -572,7 +572,6 @@ module.exports = {
{
files: [
'test/functional/services/lib/web_element_wrapper/scroll_into_view_if_necessary.js',
'src/legacy/ui/ui_render/bootstrap/kbn_bundles_loader_source.js',
'**/browser_exec_scripts/**/*.js',
],
rules: {

View file

@ -10346,7 +10346,7 @@
],
"source": {
"path": "src/core/server/rendering/types.ts",
"lineNumber": 72
"lineNumber": 73
},
"signature": [
"boolean | undefined"
@ -10355,7 +10355,7 @@
],
"source": {
"path": "src/core/server/rendering/types.ts",
"lineNumber": 67
"lineNumber": 68
},
"initialIsOpen": false
},

View file

@ -2887,7 +2887,7 @@
"type": "Function",
"label": "notHandled",
"description": [
"\nUser has no credentials.\nAllows user to access a resource when authRequired: 'optional'\nRejects a request when authRequired: true"
"\nUser has no credentials.\nAllows user to access a resource when authRequired is 'optional' or 'try'\nRejects a request when authRequired: true"
],
"source": {
"path": "src/core/server/http/lifecycle/auth.ts",
@ -4648,7 +4648,7 @@
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 171
"lineNumber": 173
}
},
{
@ -4661,7 +4661,7 @@
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 229
"lineNumber": 231
},
"signature": [
"false | ",
@ -4685,7 +4685,7 @@
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 234
"lineNumber": 236
},
"signature": [
{
@ -4701,7 +4701,7 @@
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 157
"lineNumber": 159
},
"initialIsOpen": false
},
@ -4732,14 +4732,14 @@
"type": "CompoundType",
"label": "authRequired",
"description": [
"\nDefines authentication mode for a route:\n- true. A user has to have valid credentials to access a resource\n- false. A user can access a resource without any credentials.\n- 'optional'. A user can access a resource if has valid credentials or no credentials at all.\nCan be useful when we grant access to a resource but want to identify a user if possible.\n\nDefaults to `true` if an auth mechanism is registered."
"\nDefines authentication mode for a route:\n- true. A user has to have valid credentials to access a resource\n- false. A user can access a resource without any credentials.\n- 'optional'. A user can access a resource if has valid credentials or no credentials at all.\n Can be useful when we grant access to a resource but want to identify a user if possible.\n- 'try'. A user can access a resource with valid, invalid or without any credentials.\n Users with valid credentials will be authenticated\n\nDefaults to `true` if an auth mechanism is registered."
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 116
"lineNumber": 118
},
"signature": [
"boolean | \"optional\" | undefined"
"boolean | \"optional\" | \"try\" | undefined"
]
},
{
@ -4752,7 +4752,7 @@
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 125
"lineNumber": 127
},
"signature": [
"(Method extends \"get\" ? never : boolean) | undefined"
@ -4768,7 +4768,7 @@
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 130
"lineNumber": 132
},
"signature": [
"readonly string[] | undefined"
@ -4784,7 +4784,7 @@
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 135
"lineNumber": 137
},
"signature": [
"(Method extends ",
@ -4816,7 +4816,7 @@
],
"source": {
"path": "src/core/server/http/router/route.ts",
"lineNumber": 140
"lineNumber": 142
},
"signature": [
"{ payload?: (Method extends ",

View file

@ -17,6 +17,6 @@ export interface AuthToolkit
| Property | Type | Description |
| --- | --- | --- |
| [authenticated](./kibana-plugin-core-server.authtoolkit.authenticated.md) | <code>(data?: AuthResultParams) =&gt; AuthResult</code> | Authentication is successful with given credentials, allow request to pass through |
| [notHandled](./kibana-plugin-core-server.authtoolkit.nothandled.md) | <code>() =&gt; AuthResult</code> | User has no credentials. Allows user to access a resource when authRequired: 'optional' Rejects a request when authRequired: true |
| [notHandled](./kibana-plugin-core-server.authtoolkit.nothandled.md) | <code>() =&gt; AuthResult</code> | User has no credentials. Allows user to access a resource when authRequired is 'optional' or 'try' Rejects a request when authRequired: true |
| [redirected](./kibana-plugin-core-server.authtoolkit.redirected.md) | <code>(headers: {</code><br/><code> location: string;</code><br/><code> } &amp; ResponseHeaders) =&gt; AuthResult</code> | Redirects user to another location to complete authentication when authRequired: true Allows user to access a resource without redirection when authRequired: 'optional' |

View file

@ -4,7 +4,7 @@
## AuthToolkit.notHandled property
User has no credentials. Allows user to access a resource when authRequired: 'optional' Rejects a request when authRequired: true
User has no credentials. Allows user to access a resource when authRequired is 'optional' or 'try' Rejects a request when authRequired: true
<b>Signature:</b>

View file

@ -4,12 +4,12 @@
## RouteConfigOptions.authRequired property
Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.
Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible. - 'try'. A user can access a resource with valid, invalid or without any credentials. Users with valid credentials will be authenticated
Defaults to `true` if an auth mechanism is registered.
<b>Signature:</b>
```typescript
authRequired?: boolean | 'optional';
authRequired?: boolean | 'optional' | 'try';
```

View file

@ -16,7 +16,7 @@ export interface RouteConfigOptions<Method extends RouteMethod>
| Property | Type | Description |
| --- | --- | --- |
| [authRequired](./kibana-plugin-core-server.routeconfigoptions.authrequired.md) | <code>boolean &#124; 'optional'</code> | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.<!-- -->Defaults to <code>true</code> if an auth mechanism is registered. |
| [authRequired](./kibana-plugin-core-server.routeconfigoptions.authrequired.md) | <code>boolean &#124; 'optional' &#124; 'try'</code> | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible. - 'try'. A user can access a resource with valid, invalid or without any credentials. Users with valid credentials will be authenticated<!-- -->Defaults to <code>true</code> if an auth mechanism is registered. |
| [body](./kibana-plugin-core-server.routeconfigoptions.body.md) | <code>Method extends 'get' &#124; 'options' ? undefined : RouteConfigOptionsBody</code> | Additional body options [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md)<!-- -->. |
| [tags](./kibana-plugin-core-server.routeconfigoptions.tags.md) | <code>readonly string[]</code> | Additional metadata tag strings to attach to the route. |
| [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) | <code>{</code><br/><code> payload?: Method extends 'get' &#124; 'options' ? undefined : number;</code><br/><code> idleSocket?: number;</code><br/><code> }</code> | Defines per-route timeouts. |

View file

@ -57,4 +57,21 @@ describe('CoreApp', () => {
);
});
});
describe('`/app/{id}/{any*}` route', () => {
it('is registered with the correct parameters', () => {
coreApp.setup(internalCoreSetup);
expect(httpResourcesRegistrar.register).toHaveBeenCalledWith(
{
path: '/app/{id}/{any*}',
validate: false,
options: {
authRequired: true,
},
},
expect.any(Function)
);
});
});
});

View file

@ -16,9 +16,11 @@ import { Logger } from '../logging';
/** @internal */
export class CoreApp {
private readonly logger: Logger;
constructor(core: CoreContext) {
this.logger = core.logger.get('core-app');
}
setup(coreSetup: InternalCoreSetup) {
this.logger.debug('Setting up core app.');
this.registerDefaultRoutes(coreSetup);
@ -27,7 +29,9 @@ export class CoreApp {
private registerDefaultRoutes(coreSetup: InternalCoreSetup) {
const httpSetup = coreSetup.http;
const router = httpSetup.createRouter('/');
const router = httpSetup.createRouter('');
const resources = coreSetup.httpResources.createRegistrar(router);
router.get({ path: '/', validate: false }, async (context, req, res) => {
const defaultRoute = await context.core.uiSettings.client.get<string>('defaultRoute');
const basePath = httpSetup.basePath.get(req);
@ -39,12 +43,26 @@ export class CoreApp {
},
});
});
router.get({ path: '/core', validate: false }, async (context, req, res) =>
res.ok({ body: { version: '0.0.1' } })
);
resources.register(
{
path: '/app/{id}/{any*}',
validate: false,
options: {
authRequired: true,
},
},
async (context, request, response) => {
return response.renderCoreApp();
}
);
const anonymousStatusPage = coreSetup.status.isStatusPageAnonymous();
coreSetup.httpResources.createRegistrar(router).register(
resources.register(
{
path: '/status',
validate: false,
@ -61,6 +79,7 @@ export class CoreApp {
}
);
}
private registerStaticDirs(coreSetup: InternalCoreSetup) {
coreSetup.http.registerStaticDir('/ui/{path*}', Path.resolve(__dirname, './assets'));

View file

@ -30,11 +30,11 @@ import {
SessionStorageCookieOptions,
createCookieSessionStorageFactory,
} from './cookie_session_storage';
import { IsAuthenticated, AuthStateStorage, GetAuthState } from './auth_state_storage';
import { AuthStateStorage } from './auth_state_storage';
import { AuthHeadersStorage, GetAuthHeaders } from './auth_headers_storage';
import { BasePath } from './base_path_service';
import { getEcsResponseLog } from './logging';
import { HttpServiceSetup, HttpServerInfo } from './types';
import { HttpServiceSetup, HttpServerInfo, HttpAuth } from './types';
/** @internal */
export interface HttpServerSetup {
@ -54,10 +54,7 @@ export interface HttpServerSetup {
registerOnPostAuth: HttpServiceSetup['registerOnPostAuth'];
registerOnPreResponse: HttpServiceSetup['registerOnPreResponse'];
getAuthHeaders: GetAuthHeaders;
auth: {
get: GetAuthState;
isAuthenticated: IsAuthenticated;
};
auth: HttpAuth;
getServerInfo: () => HttpServerInfo;
}
@ -228,7 +225,7 @@ export class HttpServer {
private getAuthOption(
authRequired: RouteConfigOptions<any>['authRequired'] = true
): undefined | false | { mode: 'required' | 'optional' } {
): undefined | false | { mode: 'required' | 'optional' | 'try' } {
if (this.authRegistered === false) return undefined;
if (authRequired === true) {
@ -237,6 +234,9 @@ export class HttpServer {
if (authRequired === 'optional') {
return { mode: 'optional' };
}
if (authRequired === 'try') {
return { mode: 'try' };
}
if (authRequired === false) {
return false;
}

View file

@ -65,7 +65,7 @@ describe('http service', () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
await registerAuth((req, res, toolkit) => toolkit.authenticated());
registerAuth((req, res, toolkit) => toolkit.authenticated());
const router = createRouter('');
router.get({ path: '/is-auth', validate: false }, (context, req, res) =>
@ -136,6 +136,37 @@ describe('http service', () => {
await root.start();
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false });
});
it('returns true if authenticated on a route with "try" auth', async () => {
const { http } = await root.setup();
const { createRouter, auth, registerAuth } = http;
registerAuth((req, res, toolkit) => toolkit.authenticated());
const router = createRouter('');
router.get(
{ path: '/is-auth', validate: false, options: { authRequired: 'try' } },
(context, req, res) => res.ok({ body: { isAuthenticated: auth.isAuthenticated(req) } })
);
await root.start();
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: true });
});
it('returns false if not authenticated on a route with "try" auth', async () => {
const { http } = await root.setup();
const { createRouter, auth, registerAuth } = http;
registerAuth((req, res, toolkit) => toolkit.notHandled());
const router = createRouter('');
router.get(
{ path: '/is-auth', validate: false, options: { authRequired: 'try' } },
(context, req, res) => res.ok({ body: { isAuthenticated: auth.isAuthenticated(req) } })
);
await root.start();
await kbnTestServer.request.get(root, '/is-auth').expect(200, { isAuthenticated: false });
});
});
describe('#get()', () => {
it('returns authenticated status and allow associate auth state with request', async () => {
@ -179,7 +210,7 @@ describe('http service', () => {
const { http } = await root.setup();
const { createRouter, registerAuth, auth } = http;
await registerAuth(authenticate);
registerAuth(authenticate);
const router = createRouter('');
router.get(
{ path: '/get-auth', validate: false, options: { authRequired: false } },

View file

@ -0,0 +1,249 @@
/*
* 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 * as kbnTestServer from '../../../test_helpers/kbn_server';
import { IRouter, RouteConfigOptions } from '../router';
import { HttpAuth } from '../types';
describe('http auth', () => {
let root: ReturnType<typeof kbnTestServer.createRoot>;
beforeEach(async () => {
root = kbnTestServer.createRoot({ plugins: { initialize: false } });
}, 30000);
afterEach(async () => {
await root.shutdown();
});
const registerRoute = (
router: IRouter,
auth: HttpAuth,
authRequired: RouteConfigOptions<'get'>['authRequired']
) => {
router.get(
{
path: '/route',
validate: false,
options: {
authRequired,
},
},
(context, req, res) => res.ok({ body: { authenticated: auth.isAuthenticated(req) } })
);
};
describe('when auth is registered', () => {
describe('when authRequired is `true`', () => {
it('allows authenticated access when auth returns `authenticated`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => toolkit.authenticated());
const router = createRouter('');
registerRoute(router, auth, true);
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: true });
});
it('blocks access when auth returns `notHandled`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => toolkit.notHandled());
const router = createRouter('');
registerRoute(router, auth, true);
await root.start();
await kbnTestServer.request.get(root, '/route').expect(401);
});
it('blocks access when auth returns `unauthorized`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => res.unauthorized());
const router = createRouter('');
registerRoute(router, auth, true);
await root.start();
await kbnTestServer.request.get(root, '/route').expect(401);
});
});
describe('when authRequired is `false`', () => {
it('allows anonymous access when auth returns `authenticated`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => toolkit.authenticated());
const router = createRouter('');
registerRoute(router, auth, false);
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
it('allows anonymous access when auth returns `notHandled`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => toolkit.notHandled());
const router = createRouter('');
registerRoute(router, auth, false);
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
it('allows anonymous access when auth returns `unauthorized`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => res.unauthorized());
const router = createRouter('');
registerRoute(router, auth, false);
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
});
describe('when authRequired is `optional`', () => {
it('allows authenticated access when auth returns `authenticated`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => toolkit.authenticated());
const router = createRouter('');
registerRoute(router, auth, 'optional');
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: true });
});
it('allows anonymous access when auth returns `notHandled`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => toolkit.notHandled());
const router = createRouter('');
registerRoute(router, auth, 'optional');
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
it('blocks access when auth returns `unauthorized`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => res.unauthorized());
const router = createRouter('');
registerRoute(router, auth, 'optional');
await root.start();
await kbnTestServer.request.get(root, '/route').expect(401);
});
});
describe('when authRequired is `try`', () => {
it('allows authenticated access when auth returns `authenticated`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => toolkit.authenticated());
const router = createRouter('');
registerRoute(router, auth, 'try');
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: true });
});
it('allows anonymous access when auth returns `notHandled`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => toolkit.notHandled());
const router = createRouter('');
registerRoute(router, auth, 'try');
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
it('allows anonymous access when auth returns `unauthorized`', async () => {
const { http } = await root.setup();
const { registerAuth, createRouter, auth } = http;
registerAuth((req, res, toolkit) => res.unauthorized());
const router = createRouter('');
registerRoute(router, auth, 'try');
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
});
});
describe('when auth is not registered', () => {
it('allow anonymous access to resources when `authRequired` is `true`', async () => {
const { http } = await root.setup();
const { createRouter, auth } = http;
const router = createRouter('');
registerRoute(router, auth, true);
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
it('allow anonymous access to resources when `authRequired` is `false`', async () => {
const { http } = await root.setup();
const { createRouter, auth } = http;
const router = createRouter('');
registerRoute(router, auth, false);
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
it('allow anonymous access to resources when `authRequired` is `optional`', async () => {
const { http } = await root.setup();
const { createRouter, auth } = http;
const router = createRouter('');
registerRoute(router, auth, 'optional');
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
it('allow access to resources when `authRequired` is `try`', async () => {
const { http } = await root.setup();
const { createRouter, auth } = http;
const router = createRouter('');
registerRoute(router, auth, 'try');
await root.start();
await kbnTestServer.request.get(root, '/route').expect(200, { authenticated: false });
});
});
});

View file

@ -900,7 +900,7 @@ describe('Response factory', () => {
return res.ok({
body: 'value',
headers: {
etag: '1234',
age: '42',
},
});
});
@ -910,7 +910,7 @@ describe('Response factory', () => {
const result = await supertest(innerServer.listener).get('/').expect(200);
expect(result.text).toEqual('value');
expect(result.header.etag).toBe('1234');
expect(result.header.age).toBe('42');
});
it('supports configuring non-standard headers', async () => {
@ -921,7 +921,7 @@ describe('Response factory', () => {
return res.ok({
body: 'value',
headers: {
etag: '1234',
age: '42',
'x-kibana': 'key',
},
});
@ -932,7 +932,7 @@ describe('Response factory', () => {
const result = await supertest(innerServer.listener).get('/').expect(200);
expect(result.text).toEqual('value');
expect(result.header.etag).toBe('1234');
expect(result.header.age).toBe('42');
expect(result.header['x-kibana']).toBe('key');
});
@ -944,7 +944,7 @@ describe('Response factory', () => {
return res.ok({
body: 'value',
headers: {
ETag: '1234',
AgE: '42',
},
});
});
@ -953,7 +953,7 @@ describe('Response factory', () => {
const result = await supertest(innerServer.listener).get('/').expect(200);
expect(result.header.etag).toBe('1234');
expect(result.header.age).toBe('42');
});
it('accept array of headers', async () => {
@ -1776,3 +1776,55 @@ describe('Response factory', () => {
});
});
});
describe('ETag', () => {
it('returns the `etag` header', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('');
router.get(
{
path: '/route',
validate: false,
},
(context, req, res) =>
res.ok({
body: { foo: 'bar' },
headers: {
etag: 'etag-1',
},
})
);
await server.start();
const response = await supertest(innerServer.listener)
.get('/route')
.expect(200, { foo: 'bar' });
expect(response.get('etag')).toEqual('"etag-1"');
});
it('returns a 304 when the etag value matches', async () => {
const { server: innerServer, createRouter } = await server.setup(setupDeps);
const router = createRouter('');
router.get(
{
path: '/route',
validate: false,
},
(context, req, res) =>
res.ok({
body: { foo: 'bar' },
headers: {
etag: 'etag-1',
},
})
);
await server.start();
await supertest(innerServer.listener)
.get('/route')
.set('If-None-Match', '"etag-1"')
.expect(304, '');
});
});

View file

@ -123,7 +123,7 @@ export interface AuthToolkit {
authenticated: (data?: AuthResultParams) => AuthResult;
/**
* User has no credentials.
* Allows user to access a resource when authRequired: 'optional'
* Allows user to access a resource when authRequired is 'optional' or 'try'
* Rejects a request when authRequired: true
* */
notHandled: () => AuthResult;

View file

@ -28,6 +28,7 @@ function setHeaders(response: HapiResponseObject, headers: Record<string, string
response.header(header, value as any);
}
});
applyEtag(response, headers);
return response;
}
@ -39,6 +40,7 @@ const statusHelpers = {
export class HapiResponseAdapter {
constructor(private readonly responseToolkit: HapiResponseToolkit) {}
public toBadRequest(message: string) {
const error = Boom.badRequest();
error.output.payload.message = message;
@ -115,6 +117,7 @@ export class HapiResponseAdapter {
.response(kibanaResponse.payload)
.code(kibanaResponse.status);
setHeaders(response, kibanaResponse.options.headers);
return response;
}
@ -150,3 +153,10 @@ function getErrorMessage(payload?: ResponseError): string {
function getErrorAttributes(payload?: ResponseError): ResponseErrorAttributes | undefined {
return typeof payload === 'object' && 'attributes' in payload ? payload.attributes : undefined;
}
function applyEtag(response: HapiResponseObject, headers: Record<string, string | string[]>) {
const etagHeader = Object.keys(headers).find((header) => header.toLowerCase() === 'etag');
if (etagHeader) {
response.etag(headers[etagHeader] as string);
}
}

View file

@ -109,11 +109,13 @@ export interface RouteConfigOptions<Method extends RouteMethod> {
* - true. A user has to have valid credentials to access a resource
* - false. A user can access a resource without any credentials.
* - 'optional'. A user can access a resource if has valid credentials or no credentials at all.
* Can be useful when we grant access to a resource but want to identify a user if possible.
* Can be useful when we grant access to a resource but want to identify a user if possible.
* - 'try'. A user can access a resource with valid, invalid or without any credentials.
* Users with valid credentials will be authenticated
*
* Defaults to `true` if an auth mechanism is registered.
*/
authRequired?: boolean | 'optional';
authRequired?: boolean | 'optional' | 'try';
/**
* Defines xsrf protection requirements for a route:

View file

@ -6,4 +6,10 @@
* Side Public License, v 1.
*/
export { uiMixin } from './ui_mixin';
export const getConfigMock = jest.fn();
jest.doMock('../../../apm', () => ({
getConfig: getConfigMock,
}));
export const agentMock = {} as Record<string, any>;
jest.doMock('elastic-apm-node', () => agentMock);

View file

@ -0,0 +1,76 @@
/*
* 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 { getConfigMock, agentMock } from './get_apm_config.test.mocks';
import { getApmConfig } from './get_apm_config';
const defaultApmConfig = {
active: true,
someConfigProp: 'value',
};
describe('getApmConfig', () => {
beforeEach(() => {
getConfigMock.mockReturnValue(defaultApmConfig);
});
afterEach(() => {
getConfigMock.mockReset();
agentMock.currentTransaction = null;
});
it('returns null if apm is disabled', () => {
getConfigMock.mockReturnValue({
active: false,
});
expect(getApmConfig('/path')).toBeNull();
getConfigMock.mockReturnValue(undefined);
expect(getApmConfig('/path')).toBeNull();
});
it('calls `getConfig` with the correct parameters', () => {
getApmConfig('/path');
expect(getConfigMock).toHaveBeenCalledWith('kibana-frontend');
});
it('returns the configuration from the `getConfig` call', () => {
const config = getApmConfig('/path');
expect(config).toEqual(expect.objectContaining(defaultApmConfig));
});
it('returns the requestPath as `pageLoadTransactionName`', () => {
const config = getApmConfig('/some-other-path');
expect(config).toEqual(
expect.objectContaining({
pageLoadTransactionName: '/some-other-path',
})
);
});
it('enhance the configuration with values from the current server-side transaction', () => {
agentMock.currentTransaction = {
sampled: 'sampled',
traceId: 'traceId',
ensureParentId: () => 'parentId',
} as any;
const config = getApmConfig('/some-other-path');
expect(config).toEqual(
expect.objectContaining({
pageLoadTraceId: 'traceId',
pageLoadSampled: 'sampled',
pageLoadSpanId: 'parentId',
})
);
});
});

View file

@ -6,36 +6,34 @@
* Side Public License, v 1.
*/
import { getConfig } from '../../../apm';
import agent from 'elastic-apm-node';
// @ts-expect-error apm module is a js file outside of core (need to split APM/rum configuration)
import { getConfig } from '../../../apm';
const apmEnabled = getConfig()?.active;
export function getApmConfig(requestPath) {
if (!apmEnabled) {
export const getApmConfig = (requestPath: string) => {
const baseConfig = getConfig('kibana-frontend');
if (!baseConfig?.active) {
return null;
}
const config = {
...getConfig('kibana-frontend'),
const config: Record<string, any> = {
...baseConfig,
pageLoadTransactionName: requestPath,
};
/**
* Get current active backend transaction to make distrubuted tracing
* work for rendering the app
*/
// Get current active backend transaction to make distributed tracing
// work for rendering the app
const backendTransaction = agent.currentTransaction;
if (backendTransaction) {
const { sampled, traceId } = backendTransaction;
const { sampled, traceId } = backendTransaction as any;
return {
...config,
...{
pageLoadTraceId: traceId,
pageLoadSampled: sampled,
pageLoadSpanId: backendTransaction.ensureParentId(),
},
pageLoadTraceId: traceId,
pageLoadSampled: sampled,
pageLoadSpanId: backendTransaction.ensureParentId(),
};
}
return config;
}
};

View file

@ -6,4 +6,8 @@
* Side Public License, v 1.
*/
export { AppBootstrap } from './app_bootstrap';
export const getApmConfigMock = jest.fn();
jest.doMock('./get_apm_config', () => ({
getApmConfig: getApmConfigMock,
}));

View file

@ -6,6 +6,8 @@
* Side Public License, v 1.
*/
import { getApmConfigMock } from './http_resources_service.test.mocks';
import { IRouter, RouteConfig } from '../http';
import { coreMock } from '../mocks';
@ -24,6 +26,12 @@ describe('HttpResources service', () => {
let router: jest.Mocked<IRouter>;
const kibanaRequest = httpServerMock.createKibanaRequest();
const context = { core: coreMock.createRequestHandlerContext() };
const apmConfig = { mockApmConfig: true };
beforeEach(() => {
getApmConfigMock.mockReturnValue(apmConfig);
});
describe('#createRegistrar', () => {
beforeEach(() => {
setupDeps = {
@ -52,6 +60,9 @@ describe('HttpResources service', () => {
context.core.uiSettings.client,
{
includeUserSettings: true,
vars: {
apmConfig,
},
}
);
});
@ -101,6 +112,9 @@ describe('HttpResources service', () => {
context.core.uiSettings.client,
{
includeUserSettings: false,
vars: {
apmConfig,
},
}
);
});

View file

@ -29,6 +29,7 @@ import {
HttpResourcesRequestHandler,
HttpResourcesServiceToolkit,
} from './types';
import { getApmConfig } from './get_apm_config';
export interface SetupDeps {
http: InternalHttpServiceSetup;
@ -37,6 +38,7 @@ export interface SetupDeps {
export class HttpResourcesService implements CoreService<InternalHttpResourcesSetup> {
private readonly logger: Logger;
constructor(core: CoreContext) {
this.logger = core.logger.get('http-resources');
}
@ -49,6 +51,7 @@ export class HttpResourcesService implements CoreService<InternalHttpResourcesSe
}
start() {}
stop() {}
private createRegistrar(deps: SetupDeps, router: IRouter): HttpResources {
@ -76,8 +79,12 @@ export class HttpResourcesService implements CoreService<InternalHttpResourcesSe
const cspHeader = deps.http.csp.header;
return {
async renderCoreApp(options: HttpResourcesRenderOptions = {}) {
const apmConfig = getApmConfig(request.url.pathname);
const body = await deps.rendering.render(request, context.core.uiSettings.client, {
includeUserSettings: true,
vars: {
apmConfig,
},
});
return response.ok({
@ -86,8 +93,12 @@ export class HttpResourcesService implements CoreService<InternalHttpResourcesSe
});
},
async renderAnonymousCoreApp(options: HttpResourcesRenderOptions = {}) {
const apmConfig = getApmConfig(request.url.pathname);
const body = await deps.rendering.render(request, context.core.uiSettings.client, {
includeUserSettings: false,
vars: {
apmConfig,
},
});
return response.ok({

View file

@ -133,7 +133,10 @@ export function createPluginSetupContext<TPlugin, TPluginDependencies>(
registerOnPostAuth: deps.http.registerOnPostAuth,
registerOnPreResponse: deps.http.registerOnPreResponse,
basePath: deps.http.basePath,
auth: { get: deps.http.auth.get, isAuthenticated: deps.http.auth.isAuthenticated },
auth: {
get: deps.http.auth.get,
isAuthenticated: deps.http.auth.isAuthenticated,
},
csp: deps.http.csp,
getServerInfo: deps.http.getServerInfo,
},

View file

@ -1,15 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renderTemplate interpolates templateData into string template 1`] = `
"
function kbnBundlesLoader() {
var modules = {};
function has(prop) {
return Object.prototype.hasOwnProperty.call(modules, prop);
}
function define(key, bundleRequire, bundleModuleKey) {
if (has(key)) {
throw new Error('__kbnBundles__ already has a module defined for \\"' + key + '\\"');
}
modules[key] = {
bundleRequire,
bundleModuleKey,
};
}
function get(key) {
if (!has(key)) {
throw new Error('__kbnBundles__ does not have a module defined for \\"' + key + '\\"');
}
return modules[key].bundleRequire(modules[key].bundleModuleKey);
}
return { has: has, define: define, get: get };
}
var kbnCsp = JSON.parse(document.querySelector('kbn-csp').getAttribute('data'));
window.__kbnStrictCsp__ = kbnCsp.strictCsp;
window.__kbnThemeTag__ = "{{themeTag}}";
window.__kbnPublicPath__ = {{publicPathMap}};
window.__kbnBundles__ = {{kbnBundlesLoaderSource}}
window.__kbnThemeTag__ = \\"v7\\";
window.__kbnPublicPath__ = {\\"foo\\": \\"bar\\"};
window.__kbnBundles__ = kbnBundlesLoader();
if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
var legacyBrowserError = document.getElementById('kbn_legacy_browser_error');
legacyBrowserError.style.display = 'flex';
} else {
if (!window.__kbnCspNotEnforced__ && window.console) {
window.console.log("^ A single error about an inline script not firing due to content security policy is expected!");
window.console.log(\\"^ A single error about an inline script not firing due to content security policy is expected!\\");
}
var loadingMessage = document.getElementById('kbn_loading_message');
loadingMessage.style.display = 'flex';
@ -30,7 +63,7 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
document.body.innerHTML = err.outerHTML;
}
var stylesheetTarget = document.querySelector('head meta[name="add-styles-here"]')
var stylesheetTarget = document.querySelector('head meta[name=\\"add-styles-here\\"]')
function loadStyleSheet(url, cb) {
var dom = document.createElement('link');
dom.rel = 'stylesheet';
@ -41,10 +74,9 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
document.head.insertBefore(dom, stylesheetTarget);
}
var scriptsTarget = document.querySelector('head meta[name="add-scripts-here"]')
var scriptsTarget = document.querySelector('head meta[name=\\"add-scripts-here\\"]')
function loadScript(url, cb) {
var dom = document.createElement('script');
{{!-- NOTE: async = false is used to trigger async-download/ordered-execution as outlined here: https://www.html5rocks.com/en/tutorials/speed/script-loading/ --}}
dom.async = false;
dom.src = url;
dom.addEventListener('error', failure);
@ -73,17 +105,11 @@ if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
}
load([
{{#each jsDependencyPaths}}
'{{this}}',
{{/each}}
'/js-1','/js-2'
], function () {
__kbnBundles__.get('entry/core/public').__kbnBootstrap__();
load([
{{#each styleSheetPaths}}
'{{this}}',
{{/each}}
]);
});
}
}
"
`;

View file

@ -0,0 +1,27 @@
/*
* 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.
*/
export const renderTemplateMock = jest.fn();
jest.doMock('./render_template', () => ({
renderTemplate: renderTemplateMock,
}));
export const getThemeTagMock = jest.fn();
jest.doMock('./get_theme_tag', () => ({
getThemeTag: getThemeTagMock,
}));
export const getPluginsBundlePathsMock = jest.fn();
jest.doMock('./get_plugin_bundle_paths', () => ({
getPluginsBundlePaths: getPluginsBundlePathsMock,
}));
export const getJsDependencyPathsMock = jest.fn();
jest.doMock('./get_js_dependency_paths', () => ({
getJsDependencyPaths: getJsDependencyPathsMock,
}));

View file

@ -0,0 +1,241 @@
/*
* 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 {
renderTemplateMock,
getPluginsBundlePathsMock,
getThemeTagMock,
getJsDependencyPathsMock,
} from './bootstrap_renderer.test.mocks';
import { PackageInfo } from '@kbn/config';
import { UiPlugins } from '../../plugins';
import { httpServiceMock } from '../../http/http_service.mock';
import { httpServerMock } from '../../http/http_server.mocks';
import { AuthStatus } from '../../http';
import { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.mock';
import { bootstrapRendererFactory, BootstrapRenderer } from './bootstrap_renderer';
const createPackageInfo = (parts: Partial<PackageInfo> = {}): PackageInfo => ({
branch: 'master',
buildNum: 42,
buildSha: 'buildSha',
dist: false,
version: '8.0.0',
...parts,
});
const createUiPlugins = (): UiPlugins => ({
public: new Map(),
internal: new Map(),
browserConfigs: new Map(),
});
describe('bootstrapRenderer', () => {
let auth: ReturnType<typeof httpServiceMock.createAuth>;
let uiSettingsClient: ReturnType<typeof uiSettingsServiceMock.createClient>;
let renderer: BootstrapRenderer;
let uiPlugins: UiPlugins;
let packageInfo: PackageInfo;
beforeEach(() => {
auth = httpServiceMock.createAuth();
uiSettingsClient = uiSettingsServiceMock.createClient();
uiPlugins = createUiPlugins();
packageInfo = createPackageInfo();
getThemeTagMock.mockReturnValue('v8light');
getPluginsBundlePathsMock.mockReturnValue(new Map());
renderTemplateMock.mockReturnValue('__rendered__');
getJsDependencyPathsMock.mockReturnValue([]);
renderer = bootstrapRendererFactory({
auth,
packageInfo,
uiPlugins,
serverBasePath: '/base-path',
});
});
afterEach(() => {
getThemeTagMock.mockReset();
getPluginsBundlePathsMock.mockReset();
renderTemplateMock.mockReset();
getJsDependencyPathsMock.mockReset();
});
describe('when the auth status is `authenticated`', () => {
beforeEach(() => {
auth.get.mockReturnValue({
status: 'authenticated' as AuthStatus,
state: {},
});
});
it('calls uiSettingsClient.get with the correct parameters', async () => {
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(uiSettingsClient.get).toHaveBeenCalledTimes(2);
expect(uiSettingsClient.get).toHaveBeenCalledWith('theme:darkMode');
expect(uiSettingsClient.get).toHaveBeenCalledWith('theme:version');
});
it('calls getThemeTag with the correct parameters', async () => {
uiSettingsClient.get.mockImplementation((settingName) => {
return Promise.resolve(settingName === 'theme:darkMode' ? true : 'v8');
});
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(getThemeTagMock).toHaveBeenCalledTimes(1);
expect(getThemeTagMock).toHaveBeenCalledWith({
themeVersion: 'v8',
darkMode: true,
});
});
});
describe('when the auth status is `unknown`', () => {
beforeEach(() => {
auth.get.mockReturnValue({
status: 'unknown' as AuthStatus,
state: {},
});
});
it('calls uiSettingsClient.get with the correct parameters', async () => {
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(uiSettingsClient.get).toHaveBeenCalledTimes(2);
expect(uiSettingsClient.get).toHaveBeenCalledWith('theme:darkMode');
expect(uiSettingsClient.get).toHaveBeenCalledWith('theme:version');
});
it('calls getThemeTag with the correct parameters', async () => {
uiSettingsClient.get.mockImplementation((settingName) => {
return Promise.resolve(settingName === 'theme:darkMode' ? true : 'v8');
});
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(getThemeTagMock).toHaveBeenCalledTimes(1);
expect(getThemeTagMock).toHaveBeenCalledWith({
themeVersion: 'v8',
darkMode: true,
});
});
});
describe('when the auth status is `unauthenticated`', () => {
beforeEach(() => {
auth.get.mockReturnValue({
status: 'unauthenticated' as AuthStatus,
state: {},
});
});
it('does not call uiSettingsClient.get', async () => {
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(uiSettingsClient.get).not.toHaveBeenCalled();
});
it('calls getThemeTag with the default parameters', async () => {
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(getThemeTagMock).toHaveBeenCalledTimes(1);
expect(getThemeTagMock).toHaveBeenCalledWith({
themeVersion: 'v7',
darkMode: false,
});
});
});
it('calls getPluginsBundlePaths with the correct parameters', async () => {
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(getPluginsBundlePathsMock).toHaveBeenCalledTimes(1);
expect(getPluginsBundlePathsMock).toHaveBeenCalledWith({
uiPlugins,
regularBundlePath: '/base-path/42/bundles',
});
});
// here
it('calls getJsDependencyPaths with the correct parameters', async () => {
const pluginsBundlePaths = new Map<string, unknown>();
getPluginsBundlePathsMock.mockReturnValue(pluginsBundlePaths);
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(getJsDependencyPathsMock).toHaveBeenCalledTimes(1);
expect(getJsDependencyPathsMock).toHaveBeenCalledWith(
'/base-path/42/bundles',
pluginsBundlePaths
);
});
it('calls renderTemplate with the correct parameters', async () => {
getThemeTagMock.mockReturnValue('customThemeTag');
getJsDependencyPathsMock.mockReturnValue(['path-1', 'path-2']);
const request = httpServerMock.createKibanaRequest();
await renderer({
request,
uiSettingsClient,
});
expect(renderTemplateMock).toHaveBeenCalledTimes(1);
expect(renderTemplateMock).toHaveBeenCalledWith({
themeTag: 'customThemeTag',
jsDependencyPaths: ['path-1', 'path-2'],
publicPathMap: expect.any(String),
});
});
});

View file

@ -0,0 +1,102 @@
/*
* 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 { createHash } from 'crypto';
import { PackageInfo } from '@kbn/config';
import { UiPlugins } from '../../plugins';
import { IUiSettingsClient } from '../../ui_settings';
import { HttpAuth, KibanaRequest } from '../../http';
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';
export type BootstrapRendererFactory = (factoryOptions: FactoryOptions) => BootstrapRenderer;
export type BootstrapRenderer = (options: RenderedOptions) => Promise<RendererResult>;
interface FactoryOptions {
serverBasePath: string;
packageInfo: PackageInfo;
uiPlugins: UiPlugins;
auth: HttpAuth;
}
interface RenderedOptions {
request: KibanaRequest;
uiSettingsClient: IUiSettingsClient;
}
interface RendererResult {
body: string;
etag: string;
}
export const bootstrapRendererFactory: BootstrapRendererFactory = ({
packageInfo,
serverBasePath,
uiPlugins,
auth,
}) => {
const isAuthenticated = (request: KibanaRequest) => {
const { status: authStatus } = auth.get(request);
// status is 'unknown' when auth is disabled. we just need to not be `unauthenticated` here.
return authStatus !== 'unauthenticated';
};
return async function bootstrapRenderer({ uiSettingsClient, request }) {
let darkMode = false;
let themeVersion = 'v7';
try {
const authenticated = isAuthenticated(request);
darkMode = authenticated ? await uiSettingsClient.get('theme:darkMode') : false;
themeVersion = authenticated ? await uiSettingsClient.get('theme:version') : 'v7';
} catch (e) {
// just use the default values in case of connectivity issues with ES
}
const themeTag = getThemeTag({
themeVersion,
darkMode,
});
const buildHash = packageInfo.buildNum;
const regularBundlePath = `${serverBasePath}/${buildHash}/bundles`;
const bundlePaths = getPluginsBundlePaths({
uiPlugins,
regularBundlePath,
});
const jsDependencyPaths = getJsDependencyPaths(regularBundlePath, 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': `${regularBundlePath}/kbn-ui-shared-deps/`,
...Object.fromEntries(
[...bundlePaths.entries()].map(([pluginId, plugin]) => [pluginId, plugin.publicPath])
),
});
const body = renderTemplate({
themeTag,
jsDependencyPaths,
publicPathMap,
});
const hash = createHash('sha1');
hash.update(body);
const etag = hash.digest('hex');
return {
body,
etag,
};
};
};

View file

@ -0,0 +1,32 @@
/*
* 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 { getJsDependencyPaths } from './get_js_dependency_paths';
import type { PluginInfo } from './get_plugin_bundle_paths';
describe('getJsDependencyPaths', () => {
it('returns the correct list of paths', () => {
const bundlePaths = new Map<string, PluginInfo>();
bundlePaths.set('plugin1', {
bundlePath: 'plugin1/bundle-path.js',
publicPath: 'plugin1/public-path',
});
bundlePaths.set('plugin2', {
bundlePath: 'plugin2/bundle-path.js',
publicPath: 'plugin2/public-path',
});
expect(getJsDependencyPaths('/regular-bundle-path', bundlePaths)).toEqual([
'/regular-bundle-path/kbn-ui-shared-deps/kbn-ui-shared-deps.@elastic.js',
'/regular-bundle-path/kbn-ui-shared-deps/kbn-ui-shared-deps.js',
'/regular-bundle-path/core/core.entry.js',
'plugin1/bundle-path.js',
'plugin2/bundle-path.js',
]);
});
});

View file

@ -0,0 +1,24 @@
/*
* 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 * as UiSharedDeps from '@kbn/ui-shared-deps';
import type { PluginInfo } from './get_plugin_bundle_paths';
export const getJsDependencyPaths = (
regularBundlePath: string,
bundlePaths: Map<string, PluginInfo>
) => {
return [
...UiSharedDeps.jsDepFilenames.map(
(filename) => `${regularBundlePath}/kbn-ui-shared-deps/${filename}`
),
`${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.jsFilename}`,
`${regularBundlePath}/core/core.entry.js`,
...[...bundlePaths.values()].map((plugin) => plugin.bundlePath),
];
};

View file

@ -0,0 +1,68 @@
/*
* 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 { UiPlugins } from '../../plugins';
import { getPluginsBundlePaths } from './get_plugin_bundle_paths';
const createUiPlugins = (pluginDeps: Record<string, string[]>) => {
const uiPlugins: UiPlugins = {
internal: new Map(),
public: new Map(),
browserConfigs: new Map(),
};
Object.entries(pluginDeps).forEach(([pluginId, deps]) => {
uiPlugins.internal.set(pluginId, {
requiredBundles: deps,
publicTargetDir: '',
publicAssetsDir: '',
} as any);
uiPlugins.public.set(pluginId, {
id: pluginId,
configPath: 'config-path',
optionalPlugins: [],
requiredPlugins: [],
requiredBundles: deps,
});
});
return uiPlugins;
};
describe('getPluginsBundlePaths', () => {
it('returns an entry for each plugin and their bundle dependencies', () => {
const pluginBundlePaths = getPluginsBundlePaths({
regularBundlePath: '/regular-bundle-path',
uiPlugins: createUiPlugins({
a: ['b', 'c'],
b: ['d'],
}),
});
expect([...pluginBundlePaths.keys()].sort()).toEqual(['a', 'b', 'c', 'd']);
});
it('returns correct paths for each bundle', () => {
const pluginBundlePaths = getPluginsBundlePaths({
regularBundlePath: '/regular-bundle-path',
uiPlugins: createUiPlugins({
a: ['b'],
}),
});
expect(pluginBundlePaths.get('a')).toEqual({
bundlePath: '/regular-bundle-path/plugin/a/a.plugin.js',
publicPath: '/regular-bundle-path/plugin/a/',
});
expect(pluginBundlePaths.get('b')).toEqual({
bundlePath: '/regular-bundle-path/plugin/b/b.plugin.js',
publicPath: '/regular-bundle-path/plugin/b/',
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { UiPlugins } from '../../plugins';
export interface PluginInfo {
publicPath: string;
bundlePath: string;
}
export const getPluginsBundlePaths = ({
uiPlugins,
regularBundlePath,
}: {
uiPlugins: UiPlugins;
regularBundlePath: string;
}) => {
const pluginBundlePaths = new Map<string, PluginInfo>();
const pluginsToProcess = [...uiPlugins.public.keys()];
while (pluginsToProcess.length > 0) {
const pluginId = pluginsToProcess.pop() as string;
pluginBundlePaths.set(pluginId, {
publicPath: `${regularBundlePath}/plugin/${pluginId}/`,
bundlePath: `${regularBundlePath}/plugin/${pluginId}/${pluginId}.plugin.js`,
});
const pluginBundleIds = uiPlugins.internal.get(pluginId)?.requiredBundles ?? [];
pluginBundleIds.forEach((bundleId) => {
if (!pluginBundlePaths.has(bundleId)) {
pluginsToProcess.push(bundleId);
}
});
}
return pluginBundlePaths;
};

View file

@ -0,0 +1,44 @@
/*
* 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 { getThemeTag } from './get_theme_tag';
describe('getThemeTag', () => {
it('returns the correct value for version:v7 and darkMode:false', () => {
expect(
getThemeTag({
themeVersion: 'v7',
darkMode: false,
})
).toEqual('v7light');
});
it('returns the correct value for version:v7 and darkMode:true', () => {
expect(
getThemeTag({
themeVersion: 'v7',
darkMode: true,
})
).toEqual('v7dark');
});
it('returns the correct value for version:v8 and darkMode:false', () => {
expect(
getThemeTag({
themeVersion: 'v8',
darkMode: false,
})
).toEqual('v8light');
});
it('returns the correct value for version:v8 and darkMode:true', () => {
expect(
getThemeTag({
themeVersion: 'v8',
darkMode: true,
})
).toEqual('v8dark');
});
});

View file

@ -6,8 +6,16 @@
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/legacy/ui'],
/**
* Computes the themeTag that will be used on the client-side as `__kbnThemeTag__`
* @see `packages/kbn-ui-shared-deps/theme.ts`
*/
export const getThemeTag = ({
themeVersion,
darkMode,
}: {
themeVersion: string;
darkMode: boolean;
}) => {
return `${themeVersion === 'v7' ? 'v7' : 'v8'}${darkMode ? 'dark' : 'light'}`;
};

View file

@ -6,8 +6,5 @@
* Side Public License, v 1.
*/
import { uiRenderMixin } from './ui_render';
export async function uiMixin(kbnServer) {
await kbnServer.mixin(uiRenderMixin);
}
export { registerBootstrapRoute } from './register_bootstrap_route';
export { bootstrapRendererFactory } from './bootstrap_renderer';

View file

@ -0,0 +1,42 @@
/*
* 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 { IRouter } from '../../http';
import type { BootstrapRenderer } from './bootstrap_renderer';
export const registerBootstrapRoute = ({
router,
renderer,
}: {
router: IRouter;
renderer: BootstrapRenderer;
}) => {
router.get(
{
path: '/bootstrap.js',
options: {
authRequired: 'try',
tags: ['api'],
},
validate: false,
},
async (ctx, req, res) => {
const uiSettingsClient = ctx.core.uiSettings.client;
const { body, etag } = await renderer({ uiSettingsClient, request: req });
return res.ok({
body,
headers: {
etag,
'content-type': 'application/javascript',
'cache-control': 'must-revalidate',
},
});
}
);
};

View file

@ -0,0 +1,30 @@
/*
* 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 { renderTemplate } from './render_template';
function mockParams() {
return {
themeTag: 'v7',
jsDependencyPaths: ['/js-1', '/js-2'],
styleSheetPaths: ['/style-1', '/style-2'],
publicPathMap: '{"foo": "bar"}',
};
}
describe('renderTemplate', () => {
test('resolves to a string', async () => {
const content = await renderTemplate(mockParams());
expect(typeof content).toEqual('string');
});
test('interpolates templateData into string template', async () => {
const content = await renderTemplate(mockParams());
expect(content).toMatchSnapshot();
});
});

View file

@ -0,0 +1,131 @@
/*
* 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.
*/
export interface BootstrapTemplateData {
themeTag: string;
jsDependencyPaths: string[];
publicPathMap: string;
}
export const renderTemplate = ({
themeTag,
jsDependencyPaths,
publicPathMap,
}: BootstrapTemplateData) => {
return `
function kbnBundlesLoader() {
var modules = {};
function has(prop) {
return Object.prototype.hasOwnProperty.call(modules, prop);
}
function define(key, bundleRequire, bundleModuleKey) {
if (has(key)) {
throw new Error('__kbnBundles__ already has a module defined for "' + key + '"');
}
modules[key] = {
bundleRequire,
bundleModuleKey,
};
}
function get(key) {
if (!has(key)) {
throw new Error('__kbnBundles__ does not have a module defined for "' + key + '"');
}
return modules[key].bundleRequire(modules[key].bundleModuleKey);
}
return { has: has, define: define, get: get };
}
var kbnCsp = JSON.parse(document.querySelector('kbn-csp').getAttribute('data'));
window.__kbnStrictCsp__ = kbnCsp.strictCsp;
window.__kbnThemeTag__ = "${themeTag}";
window.__kbnPublicPath__ = ${publicPathMap};
window.__kbnBundles__ = kbnBundlesLoader();
if (window.__kbnStrictCsp__ && window.__kbnCspNotEnforced__) {
var legacyBrowserError = document.getElementById('kbn_legacy_browser_error');
legacyBrowserError.style.display = 'flex';
} else {
if (!window.__kbnCspNotEnforced__ && window.console) {
window.console.log("^ A single error about an inline script not firing due to content security policy is expected!");
}
var loadingMessage = document.getElementById('kbn_loading_message');
loadingMessage.style.display = 'flex';
window.onload = function () {
function failure() {
// make subsequent calls to failure() noop
failure = function () {};
var err = document.createElement('h1');
err.style['color'] = 'white';
err.style['font-family'] = 'monospace';
err.style['text-align'] = 'center';
err.style['background'] = '#F44336';
err.style['padding'] = '25px';
err.innerText = document.querySelector('[data-error-message]').dataset.errorMessage;
document.body.innerHTML = err.outerHTML;
}
var stylesheetTarget = document.querySelector('head meta[name="add-styles-here"]')
function loadStyleSheet(url, cb) {
var dom = document.createElement('link');
dom.rel = 'stylesheet';
dom.type = 'text/css';
dom.href = url;
dom.addEventListener('error', failure);
dom.addEventListener('load', cb);
document.head.insertBefore(dom, stylesheetTarget);
}
var scriptsTarget = document.querySelector('head meta[name="add-scripts-here"]')
function loadScript(url, cb) {
var dom = document.createElement('script');
dom.async = false;
dom.src = url;
dom.addEventListener('error', failure);
dom.addEventListener('load', cb);
document.head.insertBefore(dom, scriptsTarget);
}
function load(urls, cb) {
var pending = urls.length;
urls.forEach(function (url) {
var innerCb = function () {
pending = pending - 1;
if (pending === 0 && typeof cb === 'function') {
cb();
}
}
if (typeof url !== 'string') {
load(url, innerCb);
} else if (url.slice(-4) === '.css') {
loadStyleSheet(url, innerCb);
} else {
loadScript(url, innerCb);
}
});
}
load([
${jsDependencyPaths.map((path) => `'${path}'`).join(',')}
], function () {
__kbnBundles__.get('entry/core/public').__kbnBootstrap__();
});
}
}
`;
};

View file

@ -0,0 +1,92 @@
/*
* 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 { getStylesheetPaths } from './render_utils';
describe('getStylesheetPaths', () => {
describe('when darkMode is `true`', () => {
describe('when themeVersion is `v7`', () => {
it('returns the correct list', () => {
expect(
getStylesheetPaths({
darkMode: true,
themeVersion: 'v7',
basePath: '/base-path',
buildNum: 9000,
})
).toMatchInlineSnapshot(`
Array [
"/base-path/9000/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.css",
"/base-path/9000/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.v7.dark.css",
"/base-path/node_modules/@kbn/ui-framework/dist/kui_dark.css",
"/base-path/ui/legacy_dark_theme.css",
]
`);
});
});
describe('when themeVersion is `v8`', () => {
it('returns the correct list', () => {
expect(
getStylesheetPaths({
darkMode: true,
themeVersion: 'v8',
basePath: '/base-path',
buildNum: 17,
})
).toMatchInlineSnapshot(`
Array [
"/base-path/17/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.css",
"/base-path/17/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.v8.dark.css",
"/base-path/node_modules/@kbn/ui-framework/dist/kui_dark.css",
"/base-path/ui/legacy_dark_theme.css",
]
`);
});
});
});
describe('when darkMode is `false`', () => {
describe('when themeVersion is `v7`', () => {
it('returns the correct list', () => {
expect(
getStylesheetPaths({
darkMode: false,
themeVersion: 'v7',
basePath: '/base-path',
buildNum: 42,
})
).toMatchInlineSnapshot(`
Array [
"/base-path/42/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.css",
"/base-path/42/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.v7.light.css",
"/base-path/node_modules/@kbn/ui-framework/dist/kui_light.css",
"/base-path/ui/legacy_light_theme.css",
]
`);
});
});
describe('when themeVersion is `v8`', () => {
it('returns the correct list', () => {
expect(
getStylesheetPaths({
darkMode: false,
themeVersion: 'v8',
basePath: '/base-path',
buildNum: 69,
})
).toMatchInlineSnapshot(`
Array [
"/base-path/69/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.css",
"/base-path/69/bundles/kbn-ui-shared-deps/kbn-ui-shared-deps.v8.light.css",
"/base-path/node_modules/@kbn/ui-framework/dist/kui_light.css",
"/base-path/ui/legacy_light_theme.css",
]
`);
});
});
});
});

View file

@ -0,0 +1,54 @@
/*
* 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 * as UiSharedDeps from '@kbn/ui-shared-deps';
import { PublicUiSettingsParams, UserProvidedValues } from '../ui_settings';
export const getSettingValue = <T>(
settingName: string,
settings: {
user?: Record<string, UserProvidedValues<unknown>>;
defaults: Readonly<Record<string, PublicUiSettingsParams>>;
},
convert: (raw: unknown) => T
): T => {
const value = settings.user?.[settingName]?.userValue ?? settings.defaults[settingName].value;
return convert(value);
};
export const getStylesheetPaths = ({
themeVersion,
darkMode,
basePath,
buildNum,
}: {
themeVersion: string;
darkMode: boolean;
buildNum: number;
basePath: string;
}) => {
const regularBundlePath = `${basePath}/${buildNum}/bundles`;
return [
`${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`,
...(darkMode
? [
themeVersion === 'v7'
? `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`
: `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.darkV8CssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`,
`${basePath}/ui/legacy_dark_theme.css`,
]
: [
themeVersion === 'v7'
? `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`
: `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightV8CssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`,
`${basePath}/ui/legacy_light_theme.css`,
]),
];
};

View file

@ -0,0 +1,24 @@
/*
* 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.
*/
export const bootstrapRendererMock = jest.fn();
export const registerBootstrapRouteMock = jest.fn();
export const bootstrapRendererFactoryMock = jest.fn(() => bootstrapRendererMock);
jest.doMock('./bootstrap', () => ({
registerBootstrapRoute: registerBootstrapRouteMock,
bootstrapRendererFactory: bootstrapRendererFactoryMock,
}));
export const getSettingValueMock = jest.fn();
export const getStylesheetPathsMock = jest.fn();
jest.doMock('./render_utils', () => ({
getSettingValue: getSettingValueMock,
getStylesheetPaths: getStylesheetPathsMock,
}));

View file

@ -6,6 +6,13 @@
* Side Public License, v 1.
*/
import {
registerBootstrapRouteMock,
bootstrapRendererMock,
getSettingValueMock,
getStylesheetPathsMock,
} from './rendering_service.test.mocks';
import { load } from 'cheerio';
import { httpServerMock } from '../http/http_server.mocks';
@ -42,9 +49,22 @@ describe('RenderingService', () => {
beforeEach(() => {
jest.clearAllMocks();
service = new RenderingService(mockRenderingServiceParams);
getSettingValueMock.mockImplementation((settingName: string) => settingName);
getStylesheetPathsMock.mockReturnValue(['/style-1.css', '/style-2.css']);
});
describe('setup()', () => {
it('calls `registerBootstrapRoute` with the correct parameters', async () => {
await service.setup(mockRenderingSetupDeps);
expect(registerBootstrapRouteMock).toHaveBeenCalledTimes(1);
expect(registerBootstrapRouteMock).toHaveBeenCalledWith({
router: expect.any(Object),
renderer: bootstrapRendererMock,
});
});
describe('render()', () => {
let uiSettings: ReturnType<typeof uiSettingsServiceMock.createClient>;
let render: InternalRenderingServiceSetup['render'];
@ -101,6 +121,28 @@ describe('RenderingService', () => {
expect(data).toMatchSnapshot(INJECTED_METADATA);
});
it('calls `getStylesheetPaths` with the correct parameters', async () => {
getSettingValueMock.mockImplementation((settingName: string) => {
if (settingName === 'theme:darkMode') {
return true;
}
if (settingName === 'theme:version') {
return 'v8';
}
return settingName;
});
await render(createKibanaRequest(), uiSettings);
expect(getStylesheetPathsMock).toHaveBeenCalledTimes(1);
expect(getStylesheetPathsMock).toHaveBeenCalledWith({
darkMode: true,
themeVersion: 'v8',
basePath: '/mock-server-basepath',
buildNum: expect.any(Number),
});
});
});
});
});

View file

@ -20,6 +20,8 @@ import {
InternalRenderingServiceSetup,
RenderingMetadata,
} from './types';
import { registerBootstrapRoute, bootstrapRendererFactory } from './bootstrap';
import { getSettingValue, getStylesheetPaths } from './render_utils';
/** @internal */
export class RenderingService {
@ -30,6 +32,16 @@ export class RenderingService {
status,
uiPlugins,
}: RenderingSetupDeps): Promise<InternalRenderingServiceSetup> {
const router = http.createRouter('');
const bootstrapRenderer = bootstrapRendererFactory({
uiPlugins,
serverBasePath: http.basePath.serverBasePath,
packageInfo: this.coreContext.env.packageInfo,
auth: http.auth,
});
registerBootstrapRoute({ router, renderer: bootstrapRenderer });
return {
render: async (
request,
@ -40,21 +52,32 @@ export class RenderingService {
mode: this.coreContext.env.mode,
packageInfo: this.coreContext.env.packageInfo,
};
const buildNum = env.packageInfo.buildNum;
const basePath = http.basePath.get(request);
const { serverBasePath, publicBaseUrl } = http.basePath;
const settings = {
defaults: uiSettings.getRegistered(),
user: includeUserSettings ? await uiSettings.getUserProvided() : {},
};
const darkMode = getSettingValue('theme:darkMode', settings, Boolean);
const themeVersion = getSettingValue('theme:version', settings, String);
const stylesheetPaths = getStylesheetPaths({
darkMode,
themeVersion,
basePath: serverBasePath,
buildNum,
});
const metadata: RenderingMetadata = {
strictCsp: http.csp.strict,
uiPublicUrl: `${basePath}/ui`,
bootstrapScriptUrl: `${basePath}/bootstrap.js`,
i18n: i18n.translate,
locale: i18n.getLocale(),
darkMode: settings.user?.['theme:darkMode']?.userValue
? Boolean(settings.user['theme:darkMode'].userValue)
: false,
darkMode,
stylesheetPaths,
injectedMetadata: {
version: env.packageInfo.version,
buildNumber: env.packageInfo.buildNum,
@ -74,7 +97,7 @@ export class RenderingService {
[...uiPlugins.public].map(async ([id, plugin]) => ({
id,
plugin,
config: await this.getUiConfig(uiPlugins, id),
config: await getUiConfig(uiPlugins, id),
}))
),
legacyMetadata: {
@ -89,10 +112,9 @@ export class RenderingService {
}
public async stop() {}
private async getUiConfig(uiPlugins: UiPlugins, pluginId: string) {
const browserConfig = uiPlugins.browserConfigs.get(pluginId);
return ((await browserConfig?.pipe(take(1)).toPromise()) ?? {}) as Record<string, any>;
}
}
const getUiConfig = async (uiPlugins: UiPlugins, pluginId: string) => {
const browserConfig = uiPlugins.browserConfigs.get(pluginId);
return ((await browserConfig?.pipe(take(1)).toPromise()) ?? {}) as Record<string, any>;
};

View file

@ -24,6 +24,7 @@ export interface RenderingMetadata {
i18n: typeof i18n.translate;
locale: string;
darkMode: boolean;
stylesheetPaths: string[];
injectedMetadata: {
version: string;
buildNumber: number;

View file

@ -0,0 +1,40 @@
/*
* 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 React, { FC } from 'react';
export const Logo: FC = () => (
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<g fill="none">
<path
fill="#FDD009"
d="M11.9338171,13.1522761 L19.2872353,16.5080972 L26.7065664,10.0005147 C26.8139592,9.46384495 26.866377,8.92859725 26.866377,8.36846422 C26.866377,3.78984954 23.1459864,0.0647302752 18.5719941,0.0647302752 C15.8357526,0.0647302752 13.2836129,1.41337248 11.7323847,3.67480826 L10.4983628,10.0839872 L11.9338171,13.1522761 Z"
/>
<path
fill="#23BAB1"
d="M4.32214501,20.9473399 C4.21475229,21.4841518 4.1596354,22.0410142 4.1596354,22.6044179 C4.1596354,27.1948353 7.89096419,30.9300509 12.4774572,30.9300509 C15.2361432,30.9300509 17.8007837,29.5687528 19.3495969,27.2841381 L20.5743853,20.8965739 L18.9399136,17.7698399 L11.5573744,14.401505 L4.32214501,20.9473399 Z"
/>
<path
fill="#EE5097"
d="M4.27553714,8.20847294 L9.31503995,9.3995555 L10.4190826,3.6639867 C9.73040545,3.1371289 8.88035513,2.84874358 8.00601361,2.84874358 C5.81596922,2.84874358 4.0348979,4.63252339 4.0348979,6.82484908 C4.0348979,7.30904633 4.11572655,7.77333532 4.27553714,8.20847294"
/>
<path
fill="#17A7E0"
d="M3.83806807,9.40996468 C1.58651435,10.1568087 0.0210807931,12.3172812 0.0210807931,14.6937583 C0.0210807931,17.0078087 1.45071086,19.0741436 3.5965765,19.8918041 L10.6668813,13.494428 L9.36879313,10.717795 L3.83806807,9.40996468 Z"
/>
<path
fill="#92C73D"
d="M20.6421734,27.2838537 C21.3334075,27.8156885 22.1793383,28.1057803 23.0428837,28.1057803 C25.232786,28.1057803 27.0138574,26.3228537 27.0138574,24.130528 C27.0138574,23.6470417 26.9331708,23.1827528 26.7732181,22.7477573 L21.7379769,21.5681931 L20.6421734,27.2838537 Z"
/>
<path
fill="#0678A0"
d="M21.6667227,20.2469532 L27.2099485,21.5446872 C29.4623545,20.7995495 31.0277881,18.6382239 31.0277881,16.2608936 C31.0277881,13.9511092 29.5947487,11.8871917 27.4447635,11.0719486 L20.1946185,17.4303615 L21.6667227,20.2469532 Z"
/>
</g>
</svg>
);

View file

@ -8,15 +8,25 @@
/* eslint-disable react/no-danger */
import React, { FunctionComponent } from 'react';
import { RenderingMetadata } from '../types';
import React, { FC } from 'react';
interface Props {
darkMode: RenderingMetadata['darkMode'];
darkMode: boolean;
stylesheetPaths: string[];
}
export const Styles: FunctionComponent<Props> = ({ darkMode }) => {
export const Styles: FC<Props> = ({ darkMode, stylesheetPaths }) => {
return (
<>
<InlineStyles darkMode={darkMode} />
{stylesheetPaths.map((path) => (
<link key={path} rel="stylesheet" type="text/css" href={path} />
))}
</>
);
};
const InlineStyles: FC<{ darkMode: boolean }> = ({ darkMode }) => {
return (
<style
dangerouslySetInnerHTML={{

View file

@ -11,6 +11,7 @@ import React, { FunctionComponent, createElement } from 'react';
import { RenderingMetadata } from '../types';
import { Fonts } from './fonts';
import { Styles } from './styles';
import { Logo } from './logo';
interface Props {
metadata: RenderingMetadata;
@ -21,42 +22,13 @@ export const Template: FunctionComponent<Props> = ({
uiPublicUrl,
locale,
darkMode,
stylesheetPaths,
injectedMetadata,
i18n,
bootstrapScriptUrl,
strictCsp,
},
}) => {
const logo = (
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<g fill="none">
<path
fill="#FDD009"
d="M11.9338171,13.1522761 L19.2872353,16.5080972 L26.7065664,10.0005147 C26.8139592,9.46384495 26.866377,8.92859725 26.866377,8.36846422 C26.866377,3.78984954 23.1459864,0.0647302752 18.5719941,0.0647302752 C15.8357526,0.0647302752 13.2836129,1.41337248 11.7323847,3.67480826 L10.4983628,10.0839872 L11.9338171,13.1522761 Z"
/>
<path
fill="#23BAB1"
d="M4.32214501,20.9473399 C4.21475229,21.4841518 4.1596354,22.0410142 4.1596354,22.6044179 C4.1596354,27.1948353 7.89096419,30.9300509 12.4774572,30.9300509 C15.2361432,30.9300509 17.8007837,29.5687528 19.3495969,27.2841381 L20.5743853,20.8965739 L18.9399136,17.7698399 L11.5573744,14.401505 L4.32214501,20.9473399 Z"
/>
<path
fill="#EE5097"
d="M4.27553714,8.20847294 L9.31503995,9.3995555 L10.4190826,3.6639867 C9.73040545,3.1371289 8.88035513,2.84874358 8.00601361,2.84874358 C5.81596922,2.84874358 4.0348979,4.63252339 4.0348979,6.82484908 C4.0348979,7.30904633 4.11572655,7.77333532 4.27553714,8.20847294"
/>
<path
fill="#17A7E0"
d="M3.83806807,9.40996468 C1.58651435,10.1568087 0.0210807931,12.3172812 0.0210807931,14.6937583 C0.0210807931,17.0078087 1.45071086,19.0741436 3.5965765,19.8918041 L10.6668813,13.494428 L9.36879313,10.717795 L3.83806807,9.40996468 Z"
/>
<path
fill="#92C73D"
d="M20.6421734,27.2838537 C21.3334075,27.8156885 22.1793383,28.1057803 23.0428837,28.1057803 C25.232786,28.1057803 27.0138574,26.3228537 27.0138574,24.130528 C27.0138574,23.6470417 26.9331708,23.1827528 26.7732181,22.7477573 L21.7379769,21.5681931 L20.6421734,27.2838537 Z"
/>
<path
fill="#0678A0"
d="M21.6667227,20.2469532 L27.2099485,21.5446872 C29.4623545,20.7995495 31.0277881,18.6382239 31.0277881,16.2608936 C31.0277881,13.9511092 29.5947487,11.8871917 27.4447635,11.0719486 L20.1946185,17.4303615 L21.6667227,20.2469532 Z"
/>
</g>
</svg>
);
return (
<html lang={locale}>
<head>
@ -70,7 +42,7 @@ export const Template: FunctionComponent<Props> = ({
<link rel="icon" type="image/svg+xml" href={`${uiPublicUrl}/favicons/favicon.svg`} />
<meta name="theme-color" content="#ffffff" />
<meta name="color-scheme" content="light dark" />
<Styles darkMode={darkMode} />
<Styles darkMode={darkMode} stylesheetPaths={stylesheetPaths} />
{/* Inject stylesheets into the <head> before scripts so that KP plugins with bundled styles will override them */}
<meta name="add-styles-here" />
@ -88,7 +60,7 @@ export const Template: FunctionComponent<Props> = ({
data-test-subj="kbnLoadingMessage"
>
<div className="kbnLoaderWrap">
{logo}
<Logo />
<div
className="kbnWelcomeText"
data-error-message={i18n('core.ui.welcomeErrorMessage', {
@ -103,7 +75,7 @@ export const Template: FunctionComponent<Props> = ({
</div>
<div className="kbnWelcomeView" id="kbn_legacy_browser_error" style={{ display: 'none' }}>
{logo}
<Logo />
<h2 className="kbnWelcomeTitle">
{i18n('core.ui.legacyBrowserTitle', {

View file

@ -1966,7 +1966,7 @@ export interface RouteConfig<P, Q, B, Method extends RouteMethod> {
// @public
export interface RouteConfigOptions<Method extends RouteMethod> {
authRequired?: boolean | 'optional';
authRequired?: boolean | 'optional' | 'try';
body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody;
tags?: readonly string[];
timeout?: {

View file

@ -18,7 +18,6 @@ import { loggingMixin } from './logging';
import warningsMixin from './warnings';
import configCompleteMixin from './config/complete';
import { optimizeMixin } from '../../optimize';
import { uiMixin } from '../ui';
/**
* @typedef {import('./kbn_server').KibanaConfig} KibanaConfig
@ -72,8 +71,6 @@ export default class KbnServer {
// tell the config we are done loading plugins
configCompleteMixin,
uiMixin,
// setup routes that serve the @kbn/optimizer output
optimizeMixin
)

View file

@ -1,60 +0,0 @@
/*
* 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 Handlebars from 'handlebars';
import { createHash } from 'crypto';
import { readFile } from 'fs';
import { resolve } from 'path';
import { kbnBundlesLoaderSource } from './kbn_bundles_loader_source';
export class AppBootstrap {
constructor({ templateData }) {
this.templateData = { ...templateData, kbnBundlesLoaderSource };
this._rawTemplate = undefined;
}
async getJsFile() {
if (!this._rawTemplate) {
this._rawTemplate = await loadRawTemplate();
}
const template = Handlebars.compile(this._rawTemplate, {
knownHelpersOnly: true,
noEscape: true, // this is a js file, so html escaping isn't appropriate
strict: true,
});
return template(this.templateData);
}
async getJsFileHash() {
const fileContents = await this.getJsFile();
const hash = createHash('sha1');
hash.update(fileContents);
return hash.digest('hex');
}
}
function loadRawTemplate() {
const templatePath = resolve(__dirname, 'template.js.hbs');
return readFileAsync(templatePath);
}
function readFileAsync(filePath) {
return new Promise((resolve, reject) => {
readFile(filePath, 'utf8', (err, fileContents) => {
if (err) {
reject(err);
return;
}
resolve(fileContents);
});
});
}

View file

@ -1,109 +0,0 @@
/*
* 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.
*/
const mockTemplate = `
{{appId}}
{{regularBundlePath}}
`;
jest.mock('fs', () => ({
readFile: jest.fn().mockImplementation((path, encoding, cb) => cb(null, mockTemplate)),
}));
import { AppBootstrap } from './app_bootstrap';
describe('ui_render/AppBootstrap', () => {
describe('getJsFile()', () => {
test('resolves to a string', async () => {
expect.assertions(1);
const bootstrap = new AppBootstrap(mockConfig());
const contents = await bootstrap.getJsFile();
expect(typeof contents).toEqual('string');
});
test('interpolates templateData into string template', async () => {
expect.assertions(2);
const bootstrap = new AppBootstrap(mockConfig());
const contents = await bootstrap.getJsFile();
expect(contents).toContain('123');
expect(contents).toContain('/foo/bar');
});
});
describe('getJsFileHash()', () => {
test('resolves to a 40 character string', async () => {
expect.assertions(2);
const bootstrap = new AppBootstrap(mockConfig());
const hash = await bootstrap.getJsFileHash();
expect(typeof hash).toEqual('string');
expect(hash).toHaveLength(40);
});
test('resolves to the same string for multiple calls with the same config on the same bootstrap object', async () => {
expect.assertions(1);
const bootstrap = new AppBootstrap(mockConfig());
const hash1 = await bootstrap.getJsFileHash();
const hash2 = await bootstrap.getJsFileHash();
expect(hash2).toEqual(hash1);
});
test('resolves to the same string for multiple calls with the same config on different bootstrap objects', async () => {
expect.assertions(1);
const bootstrap1 = new AppBootstrap(mockConfig());
const hash1 = await bootstrap1.getJsFileHash();
const bootstrap2 = new AppBootstrap(mockConfig());
const hash2 = await bootstrap2.getJsFileHash();
expect(hash2).toEqual(hash1);
});
test('resolves to different 40 character string with different templateData', async () => {
expect.assertions(3);
const bootstrap1 = new AppBootstrap(mockConfig());
const hash1 = await bootstrap1.getJsFileHash();
const config2 = {
...mockConfig(),
templateData: {
appId: 'not123',
regularBundlePath: 'not/foo/bar',
},
};
const bootstrap2 = new AppBootstrap(config2);
const hash2 = await bootstrap2.getJsFileHash();
expect(typeof hash2).toEqual('string');
expect(hash2).toHaveLength(40);
expect(hash2).not.toEqual(hash1);
});
});
afterAll(() => {
jest.restoreAllMocks();
});
});
function mockConfig() {
return {
templateData: {
appId: 123,
regularBundlePath: '/foo/bar',
},
};
}

View file

@ -1,40 +0,0 @@
/*
* 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.
*/
module.exports = {
kbnBundlesLoaderSource: `(${kbnBundlesLoader.toString()})();`,
};
function kbnBundlesLoader() {
var modules = {};
function has(prop) {
return Object.prototype.hasOwnProperty.call(modules, prop);
}
function define(key, bundleRequire, bundleModuleKey) {
if (has(key)) {
throw new Error('__kbnBundles__ already has a module defined for "' + key + '"');
}
modules[key] = {
bundleRequire,
bundleModuleKey,
};
}
function get(key) {
if (!has(key)) {
throw new Error('__kbnBundles__ does not have a module defined for "' + key + '"');
}
return modules[key].bundleRequire(modules[key].bundleModuleKey);
}
return { has: has, define: define, get: get };
}

View file

@ -1,9 +0,0 @@
/*
* 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.
*/
export { uiRenderMixin } from './ui_render_mixin';

View file

@ -1,165 +0,0 @@
/*
* 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 Boom from '@hapi/boom';
import * as UiSharedDeps from '@kbn/ui-shared-deps';
import { KibanaRequest } from '../../../core/server';
import { AppBootstrap } from './bootstrap';
import { getApmConfig } from '../apm';
/**
* @typedef {import('../../server/kbn_server').default} KbnServer
* @typedef {import('../../server/kbn_server').ResponseToolkit} ResponseToolkit
*/
/**
*
* @param {KbnServer} kbnServer
* @param {KbnServer['server']} server
* @param {KbnServer['config']} config
*/
export function uiRenderMixin(kbnServer, server, config) {
const authEnabled = !!server.auth.settings.default;
server.route({
path: '/bootstrap.js',
method: 'GET',
config: {
tags: ['api'],
auth: authEnabled ? { mode: 'try' } : false,
},
async handler(request, h) {
const soClient = kbnServer.newPlatform.start.core.savedObjects.getScopedClient(
KibanaRequest.from(request)
);
const uiSettings = kbnServer.newPlatform.start.core.uiSettings.asScopedToClient(soClient);
const darkMode =
!authEnabled || request.auth.isAuthenticated
? await uiSettings.get('theme:darkMode')
: false;
const themeVersion =
!authEnabled || request.auth.isAuthenticated ? await uiSettings.get('theme:version') : 'v7';
const themeTag = `${themeVersion === 'v7' ? 'v7' : 'v8'}${darkMode ? 'dark' : 'light'}`;
const buildHash = server.newPlatform.env.packageInfo.buildNum;
const basePath = config.get('server.basePath');
const regularBundlePath = `${basePath}/${buildHash}/bundles`;
const styleSheetPaths = [
`${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.baseCssDistFilename}`,
...(darkMode
? [
themeVersion === 'v7'
? `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.darkCssDistFilename}`
: `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.darkV8CssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_dark.css`,
`${basePath}/ui/legacy_dark_theme.css`,
]
: [
themeVersion === 'v7'
? `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightCssDistFilename}`
: `${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.lightV8CssDistFilename}`,
`${basePath}/node_modules/@kbn/ui-framework/dist/kui_light.css`,
`${basePath}/ui/legacy_light_theme.css`,
]),
];
const kpUiPlugins = kbnServer.newPlatform.__internals.uiPlugins;
const kpPluginPublicPaths = new Map();
const kpPluginBundlePaths = new Set();
// recursively iterate over the kpUiPlugin ids and their required bundles
// to populate kpPluginPublicPaths and kpPluginBundlePaths
(function readKpPlugins(ids) {
for (const id of ids) {
if (kpPluginPublicPaths.has(id)) {
continue;
}
kpPluginPublicPaths.set(id, `${regularBundlePath}/plugin/${id}/`);
kpPluginBundlePaths.add(`${regularBundlePath}/plugin/${id}/${id}.plugin.js`);
readKpPlugins(kpUiPlugins.internal.get(id).requiredBundles);
}
})(kpUiPlugins.public.keys());
const jsDependencyPaths = [
...UiSharedDeps.jsDepFilenames.map(
(filename) => `${regularBundlePath}/kbn-ui-shared-deps/${filename}`
),
`${regularBundlePath}/kbn-ui-shared-deps/${UiSharedDeps.jsFilename}`,
`${regularBundlePath}/core/core.entry.js`,
...kpPluginBundlePaths,
];
// 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': `${regularBundlePath}/kbn-ui-shared-deps/`,
...Object.fromEntries(kpPluginPublicPaths),
});
const bootstrap = new AppBootstrap({
templateData: {
themeTag,
jsDependencyPaths,
styleSheetPaths,
publicPathMap,
},
});
const body = await bootstrap.getJsFile();
const etag = await bootstrap.getJsFileHash();
return h
.response(body)
.header('cache-control', 'must-revalidate')
.header('content-type', 'application/javascript')
.etag(etag);
},
});
server.route({
path: '/app/{id}/{any*}',
method: 'GET',
async handler(req, h) {
try {
return await h.renderApp();
} catch (err) {
throw Boom.boomify(err);
}
},
});
async function renderApp(h) {
const { http } = kbnServer.newPlatform.setup.core;
const { savedObjects } = kbnServer.newPlatform.start.core;
const { rendering } = kbnServer.newPlatform.__internals;
const req = KibanaRequest.from(h.request);
const uiSettings = kbnServer.newPlatform.start.core.uiSettings.asScopedToClient(
savedObjects.getScopedClient(req)
);
const vars = {
apmConfig: getApmConfig(h.request.path),
};
const content = await rendering.render(h.request, uiSettings, {
includeUserSettings: true,
vars,
});
return h.response(content).type('text/html').header('content-security-policy', http.csp.header);
}
server.decorate('toolkit', 'renderApp', function () {
return renderApp(this);
});
}