Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Short URLs #107859

Merged
merged 18 commits into from
Sep 28, 2021
Merged

Short URLs #107859

Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@
"puid": "1.0.7",
"puppeteer": "^8.0.0",
"query-string": "^6.13.2",
"random-word-slugs": "^0.0.5",
"raw-loader": "^3.1.0",
"rbush": "^3.0.1",
"re-resizable": "^6.1.1",
Expand Down
2 changes: 2 additions & 0 deletions src/plugins/discover/public/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const createSetupContract = (): Setup => {
addDocView: jest.fn(),
},
locator: {
id: 'TEST_LOCATOR',
getLocation: jest.fn(),
getUrl: jest.fn(),
useUrl: jest.fn(),
Expand All @@ -37,6 +38,7 @@ const createStartContract = (): Start => {
createUrl: jest.fn(),
} as unknown) as DiscoverStart['urlGenerator'],
locator: {
id: 'TEST_LOCATOR',
getLocation: jest.fn(),
getUrl: jest.fn(),
useUrl: jest.fn(),
Expand Down
1 change: 1 addition & 0 deletions src/plugins/management/public/mocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const createSetupContract = (): ManagementSetup => ({
} as unknown) as DefinedSections,
},
locator: {
id: 'TEST_LOCATOR',
getLocation: jest.fn(async () => ({
app: 'MANAGEMENT',
path: '',
Expand Down
16 changes: 16 additions & 0 deletions src/plugins/share/common/url_service/__tests__/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@ export const urlServiceTestSetup = (partialDeps: Partial<UrlServiceDependencies>
getUrl: async () => {
throw new Error('not implemented');
},
shortUrls: {
get: () => ({
create: async () => {
throw new Error('Not implemented.');
},
get: async () => {
throw new Error('Not implemented.');
},
delete: async () => {
throw new Error('Not implemented.');
},
resolve: async () => {
throw new Error('Not implemented.');
},
}),
},
...partialDeps,
};
const service = new UrlService(deps);
Expand Down
1 change: 1 addition & 0 deletions src/plugins/share/common/url_service/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@

export * from './url_service';
export * from './locators';
export * from './short_urls';
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* 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 { SerializableRecord } from '@kbn/utility-types';
import type { KibanaLocation, LocatorDefinition } from '../../url_service';
import { shortUrlAssertValid } from './short_url_assert_valid';

export const LEGACY_SHORT_URL_LOCATOR_ID = 'LEGACY_SHORT_URL_LOCATOR';

export interface LegacyShortUrlLocatorParams extends SerializableRecord {
url: string;
}

export class LegacyShortUrlLocatorDefinition
implements LocatorDefinition<LegacyShortUrlLocatorParams> {
public readonly id = LEGACY_SHORT_URL_LOCATOR_ID;

public async getLocation(params: LegacyShortUrlLocatorParams): Promise<KibanaLocation> {
const { url } = params;

shortUrlAssertValid(url);

const match = url.match(/^.*\/app\/([^\/#]+)(.+)$/);

if (!match) {
vadimkibana marked this conversation as resolved.
Show resolved Hide resolved
throw new Error('Unexpected URL path.');
}

const [, app, path] = match;

if (!app || !path) {
throw new Error('Could not parse URL path.');
}

return {
app,
path,
state: {},
};
}
}
2 changes: 2 additions & 0 deletions src/plugins/share/common/url_service/locators/locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ export interface LocatorDependencies {
}

export class Locator<P extends SerializableRecord> implements LocatorPublic<P> {
public readonly id: string;
public readonly migrations: PersistableState<P>['migrations'];

constructor(
public readonly definition: LocatorDefinition<P>,
protected readonly deps: LocatorDependencies
) {
this.id = definition.id;
this.migrations = definition.migrations || {};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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 { shortUrlAssertValid } from './short_url_assert_valid';

describe('shortUrlAssertValid()', () => {
const invalid = [
['protocol', 'http://localhost:5601/app/kibana'],
['protocol', 'https://localhost:5601/app/kibana'],
['protocol', 'mailto:foo@bar.net'],
['protocol', 'javascript:alert("hi")'], // eslint-disable-line no-script-url
['hostname', 'localhost/app/kibana'], // according to spec, this is not a valid URL -- you cannot specify a hostname without a protocol
['hostname and port', 'local.host:5601/app/kibana'], // parser detects 'local.host' as the protocol
['hostname and auth', 'user:pass@localhost.net/app/kibana'], // parser detects 'user' as the protocol
['path traversal', '/app/../../not-kibana'], // fails because there are >2 path parts
['path traversal', '/../not-kibana'], // fails because first path part is not 'app'
['base path', '/base/app/kibana'], // fails because there are >2 path parts
['path with an extra leading slash', '//foo/app/kibana'], // parser detects 'foo' as the hostname
['path with an extra leading slash', '///app/kibana'], // parser detects '' as the hostname
['path without app', '/foo/kibana'], // fails because first path part is not 'app'
['path without appId', '/app/'], // fails because there is only one path part (leading and trailing slashes are trimmed)
];

invalid.forEach(([desc, url, error]) => {
it(`fails when url has ${desc as string}`, () => {
expect(() => shortUrlAssertValid(url as string)).toThrow();
});
});

const valid = [
'/app/kibana',
'/app/kibana/', // leading and trailing slashes are trimmed
'/app/monitoring#angular/route',
'/app/text#document-id',
'/app/some?with=query',
'/app/some?with=query#and-a-hash',
];

valid.forEach((url) => {
it(`allows ${url}`, () => {
shortUrlAssertValid(url);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/*
* 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 REGEX = /^\/app\/[^/]+.+$/;

export function shortUrlAssertValid(url: string) {
if (!REGEX.test(url) || url.includes('/../')) throw new Error(`Invalid short URL: ${url}`);
}
2 changes: 2 additions & 0 deletions src/plugins/share/common/url_service/locators/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface LocatorDefinition<P extends SerializableRecord>
* Public interface of a registered locator.
*/
export interface LocatorPublic<P extends SerializableRecord> extends PersistableState<P> {
readonly id: string;

/**
* Returns a reference to a Kibana client-side location.
*
Expand Down
16 changes: 16 additions & 0 deletions src/plugins/share/common/url_service/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ export class MockUrlService extends UrlService {
getUrl: async ({ app, path }, { absolute }) => {
return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`;
},
shortUrls: {
get: () => ({
create: async () => {
throw new Error('Not implemented.');
},
get: async () => {
throw new Error('Not implemented.');
},
delete: async () => {
throw new Error('Not implemented.');
},
resolve: async () => {
throw new Error('Not implemented.');
},
}),
},
});
}
}
Expand Down
9 changes: 9 additions & 0 deletions src/plugins/share/common/url_service/short_urls/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
* 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 * from './types';
141 changes: 141 additions & 0 deletions src/plugins/share/common/url_service/short_urls/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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 { SerializableRecord } from '@kbn/utility-types';
import { VersionedState } from 'src/plugins/kibana_utils/common';
import { LocatorPublic } from '../locators';

/**
* A factory for Short URL Service. We need this factory as the dependency
* injection is different between the server and the client. On the server,
* the Short URL Service needs a saved object client scoped to the current
* request and the current Kibana version. On the client, the Short URL Service
* needs no dependencies.
*/
export interface IShortUrlClientFactory<D> {
get(dependencies: D): IShortUrlClient;
}

/**
* CRUD-like API for short URLs.
*/
export interface IShortUrlClient {
/**
* Create a new short URL.
*
* @param locator The locator for the URL.
* @param param The parameters for the URL.
* @returns The created short URL.
*/
create<P extends SerializableRecord>(params: ShortUrlCreateParams<P>): Promise<ShortUrl<P>>;

/**
* Delete a short URL.
*
* @param slug The ID of the short URL.
*/
delete(id: string): Promise<void>;

/**
* Fetch a short URL.
*
* @param id The ID of the short URL.
*/
get(id: string): Promise<ShortUrl>;

/**
* Fetch a short URL by its slug.
*
* @param slug The slug of the short URL.
*/
resolve(slug: string): Promise<ShortUrl>;
}

/**
* New short URL creation parameters.
*/
export interface ShortUrlCreateParams<P extends SerializableRecord> {
/**
* Locator which will be used to resolve the short URL.
*/
locator: LocatorPublic<P>;

/**
* Locator parameters which will be used to resolve the short URL.
*/
params: P;

/**
* Optional, short URL slug - the part that will be used to resolve the short
* URL. This part will be visible to the user, it can have user-friendly text.
*/
slug?: string;

/**
* Whether to generate a slug automatically. If `true`, the slug will be
* a human-readable text consisting of three worlds: "<adjective>-<adjective>-<noun>".
*/
humanReadableSlug?: boolean;
}

/**
* A representation of a short URL.
*/
export interface ShortUrl<LocatorParams extends SerializableRecord = SerializableRecord> {
/**
* Serializable state of the short URL, which is stored in Kibana.
*/
readonly data: ShortUrlData<LocatorParams>;
}

/**
* A representation of a short URL's data.
*/
export interface ShortUrlData<LocatorParams extends SerializableRecord = SerializableRecord> {
/**
* Unique ID of the short URL.
*/
readonly id: string;

/**
* The slug of the short URL, the part after the `/` in the URL.
*/
readonly slug: string;

/**
* Number of times the short URL has been resolved.
*/
readonly accessCount: number;

/**
* The timestamp of the last time the short URL was resolved.
*/
readonly accessDate: number;

/**
* The timestamp when the short URL was created.
*/
readonly createDate: number;

/**
* The timestamp when the short URL was last modified.
*/
readonly locator: LocatorData<LocatorParams>;
}

/**
* Represents a serializable state of a locator. Includes locator ID, version
* and its params.
*/
export interface LocatorData<LocatorParams extends SerializableRecord = SerializableRecord>
extends VersionedState<LocatorParams> {
/**
* Locator ID.
*/
id: string;
}
12 changes: 9 additions & 3 deletions src/plugins/share/common/url_service/url_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,25 @@
*/

import { LocatorClient, LocatorClientDependencies } from './locators';
import { IShortUrlClientFactory } from './short_urls';

export type UrlServiceDependencies = LocatorClientDependencies;
export interface UrlServiceDependencies<D = unknown> extends LocatorClientDependencies {
shortUrls: IShortUrlClientFactory<D>;
}

/**
* Common URL Service client interface for server-side and client-side.
*/
export class UrlService {
export class UrlService<D = unknown> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need generic here ? seems we know deps.shortUrls needs to be there and we also know all deps for LocatorClient ? also having D being unknown by default is a bit weird and not fully type safe.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need generic here ? seems we know deps.shortUrls needs to be there and we also know all deps for LocatorClient ?

It is generic because Short URL service has different dependencies on the server vs on the browser.

also having D being unknown by default is a bit weird and not fully type safe.

Why do you think it is weird and not safe?

/**
* Client to work with locators.
*/
public readonly locators: LocatorClient;

constructor(protected readonly deps: UrlServiceDependencies) {
public readonly shortUrls: IShortUrlClientFactory<D>;

constructor(protected readonly deps: UrlServiceDependencies<D>) {
this.locators = new LocatorClient(deps);
this.shortUrls = deps.shortUrls;
}
}
Loading