mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Co-authored-by: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com>
This commit is contained in:
parent
bd669de514
commit
8e6b834540
68 changed files with 2177 additions and 788 deletions
|
@ -319,6 +319,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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -8,3 +8,4 @@
|
|||
|
||||
export * from './url_service';
|
||||
export * from './locators';
|
||||
export * from './short_urls';
|
||||
|
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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) {
|
||||
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: {},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -43,12 +43,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 || {};
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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}`);
|
||||
}
|
|
@ -53,6 +53,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.
|
||||
*
|
||||
|
|
|
@ -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.');
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
9
src/plugins/share/common/url_service/short_urls/index.ts
Normal file
9
src/plugins/share/common/url_service/short_urls/index.ts
Normal file
|
@ -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
src/plugins/share/common/url_service/short_urls/types.ts
Normal file
141
src/plugins/share/common/url_service/short_urls/types.ts
Normal file
|
@ -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;
|
||||
}
|
|
@ -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> {
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,7 +13,7 @@ describe('Url shortener', () => {
|
|||
|
||||
let postStub: jest.Mock;
|
||||
beforeEach(() => {
|
||||
postStub = jest.fn(() => Promise.resolve({ urlId: shareId }));
|
||||
postStub = jest.fn(() => Promise.resolve({ id: shareId }));
|
||||
});
|
||||
|
||||
describe('Shorten without base path', () => {
|
||||
|
@ -23,9 +23,6 @@ describe('Url shortener', () => {
|
|||
post: postStub,
|
||||
});
|
||||
expect(shortUrl).toBe(`http://localhost:5601/goto/${shareId}`);
|
||||
expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, {
|
||||
body: '{"url":"/app/kibana#123"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should shorten urls without a port', async () => {
|
||||
|
@ -34,9 +31,6 @@ describe('Url shortener', () => {
|
|||
post: postStub,
|
||||
});
|
||||
expect(shortUrl).toBe(`http://localhost/goto/${shareId}`);
|
||||
expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, {
|
||||
body: '{"url":"/app/kibana#123"}',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -49,9 +43,6 @@ describe('Url shortener', () => {
|
|||
post: postStub,
|
||||
});
|
||||
expect(shortUrl).toBe(`http://localhost:5601${basePath}/goto/${shareId}`);
|
||||
expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, {
|
||||
body: '{"url":"/app/kibana#123"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should shorten urls without a port', async () => {
|
||||
|
@ -60,9 +51,6 @@ describe('Url shortener', () => {
|
|||
post: postStub,
|
||||
});
|
||||
expect(shortUrl).toBe(`http://localhost${basePath}/goto/${shareId}`);
|
||||
expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, {
|
||||
body: '{"url":"/app/kibana#123"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should shorten urls with a query string', async () => {
|
||||
|
@ -71,9 +59,6 @@ describe('Url shortener', () => {
|
|||
post: postStub,
|
||||
});
|
||||
expect(shortUrl).toBe(`http://localhost${basePath}/goto/${shareId}`);
|
||||
expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, {
|
||||
body: '{"url":"/app/kibana?foo#123"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should shorten urls without a hash', async () => {
|
||||
|
@ -82,9 +67,6 @@ describe('Url shortener', () => {
|
|||
post: postStub,
|
||||
});
|
||||
expect(shortUrl).toBe(`http://localhost${basePath}/goto/${shareId}`);
|
||||
expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, {
|
||||
body: '{"url":"/app/kibana"}',
|
||||
});
|
||||
});
|
||||
|
||||
it('should shorten urls with a query string in the hash', async () => {
|
||||
|
@ -95,9 +77,6 @@ describe('Url shortener', () => {
|
|||
post: postStub,
|
||||
});
|
||||
expect(shortUrl).toBe(`http://localhost${basePath}/goto/${shareId}`);
|
||||
expect(postStub).toHaveBeenCalledWith(`/api/shorten_url`, {
|
||||
body: '{"url":"/app/discover#/?_g=(refreshInterval:(pause:!f,value:0),time:(from:now-15m,mode:quick,to:now))&_a=(columns:!(_source),index:%27logstash-*%27,interval:auto,query:(query_string:(analyze_wildcard:!t,query:%27*%27)),sort:!(%27@timestamp%27,desc))"}',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -8,26 +8,34 @@
|
|||
|
||||
import url from 'url';
|
||||
import { HttpStart } from 'kibana/public';
|
||||
import { CREATE_PATH, getGotoPath } from '../../common/short_url_routes';
|
||||
import { getGotoPath } from '../../common/short_url_routes';
|
||||
import { LEGACY_SHORT_URL_LOCATOR_ID } from '../../common/url_service/locators/legacy_short_url_locator';
|
||||
|
||||
export async function shortenUrl(
|
||||
absoluteUrl: string,
|
||||
{ basePath, post }: { basePath: string; post: HttpStart['post'] }
|
||||
) {
|
||||
const parsedUrl = url.parse(absoluteUrl);
|
||||
|
||||
if (!parsedUrl || !parsedUrl.path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = parsedUrl.path.replace(basePath, '');
|
||||
const hash = parsedUrl.hash ? parsedUrl.hash : '';
|
||||
const relativeUrl = path + hash;
|
||||
const body = JSON.stringify({
|
||||
locatorId: LEGACY_SHORT_URL_LOCATOR_ID,
|
||||
params: { url: relativeUrl },
|
||||
});
|
||||
|
||||
const body = JSON.stringify({ url: relativeUrl });
|
||||
const resp = await post('/api/short_url', {
|
||||
body,
|
||||
});
|
||||
|
||||
const resp = await post(CREATE_PATH, { body });
|
||||
return url.format({
|
||||
protocol: parsedUrl.protocol,
|
||||
host: parsedUrl.host,
|
||||
pathname: `${basePath}${getGotoPath(resp.urlId)}`,
|
||||
pathname: `${basePath}${getGotoPath(resp.id)}`,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -18,6 +18,22 @@ const url = new 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.');
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const createSetupContract = (): Setup => {
|
||||
|
@ -47,6 +63,7 @@ const createStartContract = (): Start => {
|
|||
const createLocator = <T extends SerializableRecord = SerializableRecord>(): jest.Mocked<
|
||||
LocatorPublic<T>
|
||||
> => ({
|
||||
id: 'MOCK_LOCATOR',
|
||||
getLocation: jest.fn(),
|
||||
getUrl: jest.fn(),
|
||||
getRedirectUrl: jest.fn(),
|
||||
|
|
|
@ -21,6 +21,7 @@ import {
|
|||
import { UrlService } from '../common/url_service';
|
||||
import { RedirectManager } from './url_service';
|
||||
import type { RedirectOptions } from '../common/url_service/locators/redirect';
|
||||
import { LegacyShortUrlLocatorDefinition } from '../common/url_service/locators/legacy_short_url_locator';
|
||||
|
||||
export interface ShareSetupDependencies {
|
||||
securityOss?: SecurityOssPluginSetup;
|
||||
|
@ -86,8 +87,6 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
|
|||
const { application, http } = core;
|
||||
const { basePath } = http;
|
||||
|
||||
application.register(createShortUrlRedirectApp(core, window.location));
|
||||
|
||||
this.url = new UrlService({
|
||||
baseUrl: basePath.publicBaseUrl || basePath.serverBasePath,
|
||||
version: this.initializerContext.env.packageInfo.version,
|
||||
|
@ -107,8 +106,28 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
|
|||
});
|
||||
return url;
|
||||
},
|
||||
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.');
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
this.url.locators.create(new LegacyShortUrlLocatorDefinition());
|
||||
|
||||
application.register(createShortUrlRedirectApp(core, window.location, this.url));
|
||||
|
||||
this.redirectManager = new RedirectManager({
|
||||
url: this.url,
|
||||
});
|
||||
|
|
|
@ -1,35 +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 { createShortUrlRedirectApp } from './short_url_redirect_app';
|
||||
import { coreMock } from '../../../../core/public/mocks';
|
||||
import { hashUrl } from '../../../kibana_utils/public';
|
||||
|
||||
jest.mock('../../../kibana_utils/public', () => ({ hashUrl: jest.fn((x) => `${x}/hashed`) }));
|
||||
|
||||
describe('short_url_redirect_app', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch url and redirect to hashed version', async () => {
|
||||
const coreSetup = coreMock.createSetup({ basePath: 'base' });
|
||||
coreSetup.http.get.mockResolvedValueOnce({ url: '/app/abc' });
|
||||
const locationMock = { pathname: '/base/goto/12345', href: '' } as Location;
|
||||
|
||||
const { mount } = createShortUrlRedirectApp(coreSetup, locationMock);
|
||||
await mount();
|
||||
|
||||
// check for fetching the complete URL
|
||||
expect(coreSetup.http.get).toHaveBeenCalledWith('/api/short_url/12345');
|
||||
// check for hashing the URL returned from the server
|
||||
expect(hashUrl).toHaveBeenCalledWith('/app/abc');
|
||||
// check for redirecting to the prepended path
|
||||
expect(locationMock.href).toEqual('base/app/abc/hashed');
|
||||
});
|
||||
});
|
|
@ -8,24 +8,43 @@
|
|||
|
||||
import { CoreSetup } from 'kibana/public';
|
||||
import { getUrlIdFromGotoRoute, getUrlPath, GOTO_PREFIX } from '../../common/short_url_routes';
|
||||
import {
|
||||
LEGACY_SHORT_URL_LOCATOR_ID,
|
||||
LegacyShortUrlLocatorParams,
|
||||
} from '../../common/url_service/locators/legacy_short_url_locator';
|
||||
import type { UrlService, ShortUrlData } from '../../common/url_service';
|
||||
|
||||
export const createShortUrlRedirectApp = (core: CoreSetup, location: Location) => ({
|
||||
export const createShortUrlRedirectApp = (
|
||||
core: CoreSetup,
|
||||
location: Location,
|
||||
urlService: UrlService
|
||||
) => ({
|
||||
id: 'short_url_redirect',
|
||||
appRoute: GOTO_PREFIX,
|
||||
chromeless: true,
|
||||
title: 'Short URL Redirect',
|
||||
async mount() {
|
||||
const urlId = getUrlIdFromGotoRoute(location.pathname);
|
||||
if (!urlId) throw new Error('Url id not present in path');
|
||||
|
||||
if (!urlId) {
|
||||
throw new Error('Url id not present in path');
|
||||
const response = await core.http.get<ShortUrlData>(getUrlPath(urlId));
|
||||
const locator = urlService.locators.get(response.locator.id);
|
||||
|
||||
if (!locator) throw new Error(`Locator [id = ${response.locator.id}] not found.`);
|
||||
|
||||
if (response.locator.id !== LEGACY_SHORT_URL_LOCATOR_ID) {
|
||||
await locator.navigate(response.locator.state, { replace: true });
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const response = await core.http.get<{ url: string }>(getUrlPath(urlId));
|
||||
const redirectUrl = response.url;
|
||||
const { hashUrl } = await import('../../../kibana_utils/public');
|
||||
const hashedUrl = hashUrl(redirectUrl);
|
||||
const url = core.http.basePath.prepend(hashedUrl);
|
||||
let redirectUrl = (response.locator.state as LegacyShortUrlLocatorParams).url;
|
||||
const storeInSessionStorage = core.uiSettings.get('state:storeInSessionStorage');
|
||||
if (storeInSessionStorage) {
|
||||
const { hashUrl } = await import('../../../kibana_utils/public');
|
||||
redirectUrl = hashUrl(redirectUrl);
|
||||
}
|
||||
|
||||
const url = core.http.basePath.prepend(redirectUrl);
|
||||
|
||||
location.href = url;
|
||||
|
||||
|
|
|
@ -9,25 +9,30 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server';
|
||||
import { createRoutes } from './routes/create_routes';
|
||||
import { url } from './saved_objects';
|
||||
import { CSV_SEPARATOR_SETTING, CSV_QUOTE_VALUES_SETTING } from '../common/constants';
|
||||
import { UrlService } from '../common/url_service';
|
||||
import { ServerUrlService, ServerShortUrlClientFactory } from './url_service';
|
||||
import { registerUrlServiceRoutes } from './url_service/http/register_url_service_routes';
|
||||
import { LegacyShortUrlLocatorDefinition } from '../common/url_service/locators/legacy_short_url_locator';
|
||||
|
||||
/** @public */
|
||||
export interface SharePluginSetup {
|
||||
url: UrlService;
|
||||
url: ServerUrlService;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface SharePluginStart {
|
||||
url: UrlService;
|
||||
url: ServerUrlService;
|
||||
}
|
||||
|
||||
export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
|
||||
private url?: UrlService;
|
||||
private url?: ServerUrlService;
|
||||
private version: string;
|
||||
|
||||
constructor(private readonly initializerContext: PluginInitializerContext) {}
|
||||
constructor(private readonly initializerContext: PluginInitializerContext) {
|
||||
this.version = initializerContext.env.packageInfo.version;
|
||||
}
|
||||
|
||||
public setup(core: CoreSetup) {
|
||||
this.url = new UrlService({
|
||||
|
@ -39,9 +44,17 @@ export class SharePlugin implements Plugin<SharePluginSetup, SharePluginStart> {
|
|||
getUrl: async () => {
|
||||
throw new Error('Locator .getUrl() currently is not supported on the server.');
|
||||
},
|
||||
shortUrls: new ServerShortUrlClientFactory({
|
||||
currentVersion: this.version,
|
||||
}),
|
||||
});
|
||||
|
||||
createRoutes(core, this.initializerContext.logger.get());
|
||||
this.url.locators.create(new LegacyShortUrlLocatorDefinition());
|
||||
|
||||
const router = core.http.createRouter();
|
||||
|
||||
registerUrlServiceRoutes(core, router, this.url);
|
||||
|
||||
core.savedObjects.registerType(url);
|
||||
core.uiSettings.register({
|
||||
[CSV_SEPARATOR_SETTING]: {
|
||||
|
|
|
@ -1,23 +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 { CoreSetup, Logger } from 'kibana/server';
|
||||
|
||||
import { shortUrlLookupProvider } from './lib/short_url_lookup';
|
||||
import { createGotoRoute } from './goto';
|
||||
import { createShortenUrlRoute } from './shorten_url';
|
||||
import { createGetterRoute } from './get';
|
||||
|
||||
export function createRoutes({ http }: CoreSetup, logger: Logger) {
|
||||
const shortUrlLookup = shortUrlLookupProvider({ logger });
|
||||
const router = http.createRouter();
|
||||
|
||||
createGotoRoute({ router, shortUrlLookup, http });
|
||||
createGetterRoute({ router, shortUrlLookup, http });
|
||||
createShortenUrlRoute({ router, shortUrlLookup });
|
||||
}
|
|
@ -1,45 +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 { CoreSetup, IRouter } from 'kibana/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import { shortUrlAssertValid } from './lib/short_url_assert_valid';
|
||||
import { ShortUrlLookupService } from './lib/short_url_lookup';
|
||||
import { getUrlPath } from '../../common/short_url_routes';
|
||||
|
||||
export const createGetterRoute = ({
|
||||
router,
|
||||
shortUrlLookup,
|
||||
http,
|
||||
}: {
|
||||
router: IRouter;
|
||||
shortUrlLookup: ShortUrlLookupService;
|
||||
http: CoreSetup['http'];
|
||||
}) => {
|
||||
router.get(
|
||||
{
|
||||
path: getUrlPath('{urlId}'),
|
||||
validate: {
|
||||
params: schema.object({ urlId: schema.string() }),
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(async function (context, request, response) {
|
||||
const url = await shortUrlLookup.getUrl(request.params.urlId, {
|
||||
savedObjects: context.core.savedObjects.client,
|
||||
});
|
||||
shortUrlAssertValid(url);
|
||||
|
||||
return response.ok({
|
||||
body: {
|
||||
url,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
|
@ -1,59 +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 { CoreSetup, IRouter } from 'kibana/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
import { modifyUrl } from '@kbn/std';
|
||||
|
||||
import { shortUrlAssertValid } from './lib/short_url_assert_valid';
|
||||
import { ShortUrlLookupService } from './lib/short_url_lookup';
|
||||
import { getGotoPath } from '../../common/short_url_routes';
|
||||
|
||||
export const createGotoRoute = ({
|
||||
router,
|
||||
shortUrlLookup,
|
||||
http,
|
||||
}: {
|
||||
router: IRouter;
|
||||
shortUrlLookup: ShortUrlLookupService;
|
||||
http: CoreSetup['http'];
|
||||
}) => {
|
||||
http.resources.register(
|
||||
{
|
||||
path: getGotoPath('{urlId}'),
|
||||
validate: {
|
||||
params: schema.object({ urlId: schema.string() }),
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(async function (context, request, response) {
|
||||
const url = await shortUrlLookup.getUrl(request.params.urlId, {
|
||||
savedObjects: context.core.savedObjects.client,
|
||||
});
|
||||
shortUrlAssertValid(url);
|
||||
|
||||
const uiSettings = context.core.uiSettings.client;
|
||||
const stateStoreInSessionStorage = await uiSettings.get('state:storeInSessionStorage');
|
||||
if (!stateStoreInSessionStorage) {
|
||||
const basePath = http.basePath.get(request);
|
||||
|
||||
const prependedUrl = modifyUrl(url, (parts) => {
|
||||
if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) {
|
||||
parts.pathname = `${basePath}${parts.pathname}`;
|
||||
}
|
||||
});
|
||||
return response.redirected({
|
||||
headers: {
|
||||
location: prependedUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return response.renderCoreApp();
|
||||
})
|
||||
);
|
||||
};
|
|
@ -1,55 +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 { shortUrlAssertValid } from './short_url_assert_valid';
|
||||
|
||||
const PROTOCOL_ERROR = /^Short url targets cannot have a protocol/;
|
||||
const HOSTNAME_ERROR = /^Short url targets cannot have a hostname/;
|
||||
const PATH_ERROR = /^Short url target path must be in the format/;
|
||||
|
||||
describe('shortUrlAssertValid()', () => {
|
||||
const invalid = [
|
||||
['protocol', 'http://localhost:5601/app/kibana', PROTOCOL_ERROR],
|
||||
['protocol', 'https://localhost:5601/app/kibana', PROTOCOL_ERROR],
|
||||
['protocol', 'mailto:foo@bar.net', PROTOCOL_ERROR],
|
||||
['protocol', 'javascript:alert("hi")', PROTOCOL_ERROR], // eslint-disable-line no-script-url
|
||||
['hostname', 'localhost/app/kibana', PATH_ERROR], // 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', PROTOCOL_ERROR], // parser detects 'local.host' as the protocol
|
||||
['hostname and auth', 'user:pass@localhost.net/app/kibana', PROTOCOL_ERROR], // parser detects 'user' as the protocol
|
||||
['path traversal', '/app/../../not-kibana', PATH_ERROR], // fails because there are >2 path parts
|
||||
['path traversal', '/../not-kibana', PATH_ERROR], // fails because first path part is not 'app'
|
||||
['deep path', '/app/kibana/foo', PATH_ERROR], // fails because there are >2 path parts
|
||||
['deeper path', '/app/kibana/foo/bar', PATH_ERROR], // fails because there are >2 path parts
|
||||
['base path', '/base/app/kibana', PATH_ERROR], // fails because there are >2 path parts
|
||||
['path with an extra leading slash', '//foo/app/kibana', HOSTNAME_ERROR], // parser detects 'foo' as the hostname
|
||||
['path with an extra leading slash', '///app/kibana', HOSTNAME_ERROR], // parser detects '' as the hostname
|
||||
['path without app', '/foo/kibana', PATH_ERROR], // fails because first path part is not 'app'
|
||||
['path without appId', '/app/', PATH_ERROR], // 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)).toThrowError(error);
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,34 +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 { parse } from 'url';
|
||||
import { trim } from 'lodash';
|
||||
import Boom from '@hapi/boom';
|
||||
|
||||
export function shortUrlAssertValid(url: string) {
|
||||
const { protocol, hostname, pathname } = parse(
|
||||
url,
|
||||
false /* parseQueryString */,
|
||||
true /* slashesDenoteHost */
|
||||
);
|
||||
|
||||
if (protocol !== null) {
|
||||
throw Boom.notAcceptable(`Short url targets cannot have a protocol, found "${protocol}"`);
|
||||
}
|
||||
|
||||
if (hostname !== null) {
|
||||
throw Boom.notAcceptable(`Short url targets cannot have a hostname, found "${hostname}"`);
|
||||
}
|
||||
|
||||
const pathnameParts = trim(pathname === null ? undefined : pathname, '/').split('/');
|
||||
if (pathnameParts.length !== 2 || pathnameParts[0] !== 'app' || !pathnameParts[1]) {
|
||||
throw Boom.notAcceptable(
|
||||
`Short url target path must be in the format "/app/{{appId}}", found "${pathname}"`
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,110 +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 { shortUrlLookupProvider, ShortUrlLookupService, UrlAttributes } from './short_url_lookup';
|
||||
import { SavedObjectsClientContract, SavedObject } from 'kibana/server';
|
||||
|
||||
import { savedObjectsClientMock, loggingSystemMock } from '../../../../../core/server/mocks';
|
||||
|
||||
describe('shortUrlLookupProvider', () => {
|
||||
const ID = 'bf00ad16941fc51420f91a93428b27a0';
|
||||
const TYPE = 'url';
|
||||
const URL = 'http://elastic.co';
|
||||
|
||||
let savedObjects: jest.Mocked<SavedObjectsClientContract>;
|
||||
let deps: { savedObjects: SavedObjectsClientContract };
|
||||
let shortUrl: ShortUrlLookupService;
|
||||
|
||||
beforeEach(() => {
|
||||
savedObjects = savedObjectsClientMock.create();
|
||||
savedObjects.create.mockResolvedValue({ id: ID } as SavedObject<UrlAttributes>);
|
||||
deps = { savedObjects };
|
||||
shortUrl = shortUrlLookupProvider({ logger: loggingSystemMock.create().get() });
|
||||
});
|
||||
|
||||
describe('generateUrlId', () => {
|
||||
it('returns the document id', async () => {
|
||||
const id = await shortUrl.generateUrlId(URL, deps);
|
||||
expect(id).toEqual(ID);
|
||||
});
|
||||
|
||||
it('provides correct arguments to savedObjectsClient', async () => {
|
||||
await shortUrl.generateUrlId(URL, { savedObjects });
|
||||
|
||||
expect(savedObjects.create).toHaveBeenCalledTimes(1);
|
||||
const [type, attributes, options] = savedObjects.create.mock.calls[0];
|
||||
|
||||
expect(type).toEqual(TYPE);
|
||||
expect(Object.keys(attributes as UrlAttributes).sort()).toEqual([
|
||||
'accessCount',
|
||||
'accessDate',
|
||||
'createDate',
|
||||
'url',
|
||||
]);
|
||||
expect((attributes as UrlAttributes).url).toEqual(URL);
|
||||
expect(options!.id).toEqual(ID);
|
||||
});
|
||||
|
||||
it('passes persists attributes', async () => {
|
||||
await shortUrl.generateUrlId(URL, deps);
|
||||
|
||||
expect(savedObjects.create).toHaveBeenCalledTimes(1);
|
||||
const [type, attributes] = savedObjects.create.mock.calls[0];
|
||||
|
||||
expect(type).toEqual(TYPE);
|
||||
expect(Object.keys(attributes as UrlAttributes).sort()).toEqual([
|
||||
'accessCount',
|
||||
'accessDate',
|
||||
'createDate',
|
||||
'url',
|
||||
]);
|
||||
expect((attributes as UrlAttributes).url).toEqual(URL);
|
||||
});
|
||||
|
||||
it('gracefully handles version conflict', async () => {
|
||||
const error = savedObjects.errors.decorateConflictError(new Error());
|
||||
savedObjects.create.mockImplementation(() => {
|
||||
throw error;
|
||||
});
|
||||
const id = await shortUrl.generateUrlId(URL, deps);
|
||||
expect(id).toEqual(ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUrl', () => {
|
||||
beforeEach(() => {
|
||||
const attributes = { accessCount: 2, url: URL };
|
||||
savedObjects.get.mockResolvedValue({ id: ID, attributes, type: 'url', references: [] });
|
||||
});
|
||||
|
||||
it('provides the ID to savedObjectsClient', async () => {
|
||||
await shortUrl.getUrl(ID, { savedObjects });
|
||||
|
||||
expect(savedObjects.get).toHaveBeenCalledTimes(1);
|
||||
expect(savedObjects.get).toHaveBeenCalledWith(TYPE, ID);
|
||||
});
|
||||
|
||||
it('returns the url', async () => {
|
||||
const response = await shortUrl.getUrl(ID, deps);
|
||||
expect(response).toEqual(URL);
|
||||
});
|
||||
|
||||
it('increments accessCount', async () => {
|
||||
await shortUrl.getUrl(ID, { savedObjects });
|
||||
|
||||
expect(savedObjects.update).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [type, id, attributes] = savedObjects.update.mock.calls[0];
|
||||
|
||||
expect(type).toEqual(TYPE);
|
||||
expect(id).toEqual(ID);
|
||||
expect(Object.keys(attributes).sort()).toEqual(['accessCount', 'accessDate']);
|
||||
expect((attributes as UrlAttributes).accessCount).toEqual(3);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,77 +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 crypto from 'crypto';
|
||||
import { get } from 'lodash';
|
||||
|
||||
import { Logger, SavedObject, SavedObjectsClientContract } from 'kibana/server';
|
||||
|
||||
export interface ShortUrlLookupService {
|
||||
generateUrlId(url: string, deps: { savedObjects: SavedObjectsClientContract }): Promise<string>;
|
||||
getUrl(url: string, deps: { savedObjects: SavedObjectsClientContract }): Promise<string>;
|
||||
}
|
||||
|
||||
export interface UrlAttributes {
|
||||
url: string;
|
||||
accessCount: number;
|
||||
createDate: number;
|
||||
accessDate: number;
|
||||
}
|
||||
|
||||
export function shortUrlLookupProvider({ logger }: { logger: Logger }): ShortUrlLookupService {
|
||||
async function updateMetadata(
|
||||
doc: SavedObject<UrlAttributes>,
|
||||
{ savedObjects }: { savedObjects: SavedObjectsClientContract }
|
||||
) {
|
||||
try {
|
||||
await savedObjects.update<UrlAttributes>('url', doc.id, {
|
||||
accessDate: new Date().valueOf(),
|
||||
accessCount: get(doc, 'attributes.accessCount', 0) + 1,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn('Warning: Error updating url metadata');
|
||||
logger.warn(error);
|
||||
// swallow errors. It isn't critical if there is no update.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async generateUrlId(url, { savedObjects }) {
|
||||
const id = crypto.createHash('md5').update(url).digest('hex');
|
||||
const { isConflictError } = savedObjects.errors;
|
||||
|
||||
try {
|
||||
const doc = await savedObjects.create<UrlAttributes>(
|
||||
'url',
|
||||
{
|
||||
url,
|
||||
accessCount: 0,
|
||||
createDate: new Date().valueOf(),
|
||||
accessDate: new Date().valueOf(),
|
||||
},
|
||||
{ id }
|
||||
);
|
||||
|
||||
return doc.id;
|
||||
} catch (error) {
|
||||
if (isConflictError(error)) {
|
||||
return id;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getUrl(id, { savedObjects }) {
|
||||
const doc = await savedObjects.get<UrlAttributes>('url', id);
|
||||
updateMetadata(doc, { savedObjects });
|
||||
|
||||
return doc.attributes.url;
|
||||
},
|
||||
};
|
||||
}
|
|
@ -1,38 +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 { IRouter } from 'kibana/server';
|
||||
import { schema } from '@kbn/config-schema';
|
||||
|
||||
import { shortUrlAssertValid } from './lib/short_url_assert_valid';
|
||||
import { ShortUrlLookupService } from './lib/short_url_lookup';
|
||||
import { CREATE_PATH } from '../../common/short_url_routes';
|
||||
|
||||
export const createShortenUrlRoute = ({
|
||||
shortUrlLookup,
|
||||
router,
|
||||
}: {
|
||||
shortUrlLookup: ShortUrlLookupService;
|
||||
router: IRouter;
|
||||
}) => {
|
||||
router.post(
|
||||
{
|
||||
path: CREATE_PATH,
|
||||
validate: {
|
||||
body: schema.object({ url: schema.string() }),
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(async function (context, request, response) {
|
||||
shortUrlAssertValid(request.body.url);
|
||||
const urlId = await shortUrlLookup.generateUrlId(request.body.url, {
|
||||
savedObjects: context.core.savedObjects.client,
|
||||
});
|
||||
return response.ok({ body: { urlId } });
|
||||
})
|
||||
);
|
||||
};
|
|
@ -28,6 +28,14 @@ export const url: SavedObjectsType = {
|
|||
},
|
||||
mappings: {
|
||||
properties: {
|
||||
slug: {
|
||||
type: 'text',
|
||||
fields: {
|
||||
keyword: {
|
||||
type: 'keyword',
|
||||
},
|
||||
},
|
||||
},
|
||||
accessCount: {
|
||||
type: 'long',
|
||||
},
|
||||
|
@ -37,6 +45,9 @@ export const url: SavedObjectsType = {
|
|||
createDate: {
|
||||
type: 'date',
|
||||
},
|
||||
// Legacy field - contains already pre-formatted final URL.
|
||||
// This is here to support old saved objects that have this field.
|
||||
// TODO: Remove this field and execute a migration to the new format.
|
||||
url: {
|
||||
type: 'text',
|
||||
fields: {
|
||||
|
@ -46,6 +57,11 @@ export const url: SavedObjectsType = {
|
|||
},
|
||||
},
|
||||
},
|
||||
// Information needed to load and execute a locator.
|
||||
locatorJSON: {
|
||||
type: 'text',
|
||||
index: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* 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 { CoreSetup, IRouter } from 'kibana/server';
|
||||
import { ServerUrlService } from '../types';
|
||||
import { registerCreateRoute } from './short_urls/register_create_route';
|
||||
import { registerGetRoute } from './short_urls/register_get_route';
|
||||
import { registerDeleteRoute } from './short_urls/register_delete_route';
|
||||
import { registerResolveRoute } from './short_urls/register_resolve_route';
|
||||
import { registerGotoRoute } from './short_urls/register_goto_route';
|
||||
import { registerShortenUrlRoute } from './short_urls/register_shorten_url_route';
|
||||
|
||||
export const registerUrlServiceRoutes = (
|
||||
core: CoreSetup,
|
||||
router: IRouter,
|
||||
url: ServerUrlService
|
||||
) => {
|
||||
registerCreateRoute(router, url);
|
||||
registerGetRoute(router, url);
|
||||
registerDeleteRoute(router, url);
|
||||
registerResolveRoute(router, url);
|
||||
registerGotoRoute(router, core);
|
||||
registerShortenUrlRoute(router, core);
|
||||
};
|
|
@ -0,0 +1,66 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
import { IRouter } from 'kibana/server';
|
||||
import { ServerUrlService } from '../../types';
|
||||
|
||||
export const registerCreateRoute = (router: IRouter, url: ServerUrlService) => {
|
||||
router.post(
|
||||
{
|
||||
path: '/api/short_url',
|
||||
validate: {
|
||||
body: schema.object({
|
||||
locatorId: schema.string({
|
||||
minLength: 1,
|
||||
maxLength: 255,
|
||||
}),
|
||||
slug: schema.string({
|
||||
defaultValue: '',
|
||||
minLength: 3,
|
||||
maxLength: 255,
|
||||
}),
|
||||
humanReadableSlug: schema.boolean({
|
||||
defaultValue: false,
|
||||
}),
|
||||
params: schema.object({}, { unknowns: 'allow' }),
|
||||
}),
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(async (ctx, req, res) => {
|
||||
const savedObjects = ctx.core.savedObjects.client;
|
||||
const shortUrls = url.shortUrls.get({ savedObjects });
|
||||
const { locatorId, params, slug, humanReadableSlug } = req.body;
|
||||
const locator = url.locators.get(locatorId);
|
||||
|
||||
if (!locator) {
|
||||
return res.customError({
|
||||
statusCode: 409,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: 'Locator not found.',
|
||||
});
|
||||
}
|
||||
|
||||
const shortUrl = await shortUrls.create({
|
||||
locator,
|
||||
params,
|
||||
slug,
|
||||
humanReadableSlug,
|
||||
});
|
||||
|
||||
return res.ok({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: shortUrl.data,
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
import { IRouter } from 'kibana/server';
|
||||
import { ServerUrlService } from '../../types';
|
||||
|
||||
export const registerDeleteRoute = (router: IRouter, url: ServerUrlService) => {
|
||||
router.delete(
|
||||
{
|
||||
path: '/api/short_url/{id}',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
id: schema.string({
|
||||
minLength: 4,
|
||||
maxLength: 128,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(async (ctx, req, res) => {
|
||||
const id = req.params.id;
|
||||
const savedObjects = ctx.core.savedObjects.client;
|
||||
const shortUrls = url.shortUrls.get({ savedObjects });
|
||||
|
||||
await shortUrls.delete(id);
|
||||
|
||||
return res.ok({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: 'null',
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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 { schema } from '@kbn/config-schema';
|
||||
import { IRouter } from 'kibana/server';
|
||||
import { ServerUrlService } from '../../types';
|
||||
|
||||
export const registerGetRoute = (router: IRouter, url: ServerUrlService) => {
|
||||
router.get(
|
||||
{
|
||||
path: '/api/short_url/{id}',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
id: schema.string({
|
||||
minLength: 4,
|
||||
maxLength: 128,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(async (ctx, req, res) => {
|
||||
const id = req.params.id;
|
||||
const savedObjects = ctx.core.savedObjects.client;
|
||||
const shortUrls = url.shortUrls.get({ savedObjects });
|
||||
const shortUrl = await shortUrls.get(id);
|
||||
|
||||
return res.ok({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: shortUrl.data,
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* 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 { schema } from '@kbn/config-schema';
|
||||
import { CoreSetup, IRouter } from 'kibana/server';
|
||||
|
||||
/**
|
||||
* This endpoint maintains the legacy /goto/<short_url_id> route. It loads the
|
||||
* /app/goto/<short_url_id> app which handles the redirection.
|
||||
*/
|
||||
export const registerGotoRoute = (router: IRouter, core: CoreSetup) => {
|
||||
core.http.resources.register(
|
||||
{
|
||||
path: '/goto/{id}',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
id: schema.string({
|
||||
minLength: 4,
|
||||
maxLength: 128,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(async (ctx, req, res) => {
|
||||
return res.renderCoreApp();
|
||||
})
|
||||
);
|
||||
};
|
|
@ -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 { schema } from '@kbn/config-schema';
|
||||
import { IRouter } from 'kibana/server';
|
||||
import { ServerUrlService } from '../../types';
|
||||
|
||||
export const registerResolveRoute = (router: IRouter, url: ServerUrlService) => {
|
||||
router.get(
|
||||
{
|
||||
path: '/api/short_url/_slug/{slug}',
|
||||
validate: {
|
||||
params: schema.object({
|
||||
slug: schema.string({
|
||||
minLength: 4,
|
||||
maxLength: 128,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
router.handleLegacyErrors(async (ctx, req, res) => {
|
||||
const slug = req.params.slug;
|
||||
const savedObjects = ctx.core.savedObjects.client;
|
||||
const shortUrls = url.shortUrls.get({ savedObjects });
|
||||
const shortUrl = await shortUrls.resolve(slug);
|
||||
|
||||
return res.ok({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: shortUrl.data,
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* 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 { CoreSetup, IRouter } from 'kibana/server';
|
||||
|
||||
export const registerShortenUrlRoute = (router: IRouter, core: CoreSetup) => {
|
||||
core.http.resources.register(
|
||||
{
|
||||
path: '/api/shorten_url',
|
||||
validate: {},
|
||||
},
|
||||
router.handleLegacyErrors(async (ctx, req, res) => {
|
||||
return res.badRequest({
|
||||
body: 'This endpoint is no longer supported. Please use the new URL shortening service.',
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
10
src/plugins/share/server/url_service/index.ts
Normal file
10
src/plugins/share/server/url_service/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* 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';
|
||||
export * from './short_urls';
|
11
src/plugins/share/server/url_service/short_urls/index.ts
Normal file
11
src/plugins/share/server/url_service/short_urls/index.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* 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';
|
||||
export * from './short_url_client';
|
||||
export * from './short_url_client_factory';
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* 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 { ServerShortUrlClientFactory } from './short_url_client_factory';
|
||||
import { UrlService } from '../../../common/url_service';
|
||||
import { LegacyShortUrlLocatorDefinition } from '../../../common/url_service/locators/legacy_short_url_locator';
|
||||
import { MemoryShortUrlStorage } from './storage/memory_short_url_storage';
|
||||
|
||||
const setup = () => {
|
||||
const currentVersion = '1.2.3';
|
||||
const service = new UrlService({
|
||||
getUrl: () => {
|
||||
throw new Error('Not implemented.');
|
||||
},
|
||||
navigate: () => {
|
||||
throw new Error('Not implemented.');
|
||||
},
|
||||
shortUrls: new ServerShortUrlClientFactory({
|
||||
currentVersion,
|
||||
}),
|
||||
});
|
||||
const definition = new LegacyShortUrlLocatorDefinition();
|
||||
const locator = service.locators.create(definition);
|
||||
const storage = new MemoryShortUrlStorage();
|
||||
const client = service.shortUrls.get({ storage });
|
||||
|
||||
return {
|
||||
service,
|
||||
client,
|
||||
storage,
|
||||
locator,
|
||||
definition,
|
||||
currentVersion,
|
||||
};
|
||||
};
|
||||
|
||||
describe('ServerShortUrlClient', () => {
|
||||
describe('.create()', () => {
|
||||
test('can create a short URL', async () => {
|
||||
const { client, locator, currentVersion } = setup();
|
||||
const shortUrl = await client.create({
|
||||
locator,
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
});
|
||||
|
||||
expect(shortUrl).toMatchObject({
|
||||
data: {
|
||||
accessCount: 0,
|
||||
accessDate: expect.any(Number),
|
||||
createDate: expect.any(Number),
|
||||
slug: expect.any(String),
|
||||
locator: {
|
||||
id: locator.id,
|
||||
version: currentVersion,
|
||||
state: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
},
|
||||
id: expect.any(String),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.resolve()', () => {
|
||||
test('can get short URL by its slug', async () => {
|
||||
const { client, locator } = setup();
|
||||
const shortUrl1 = await client.create({
|
||||
locator,
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
});
|
||||
const shortUrl2 = await client.resolve(shortUrl1.data.slug);
|
||||
|
||||
expect(shortUrl2.data).toMatchObject(shortUrl1.data);
|
||||
});
|
||||
|
||||
test('can create short URL with custom slug', async () => {
|
||||
const { client, locator } = setup();
|
||||
await client.create({
|
||||
locator,
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
});
|
||||
const shortUrl1 = await client.create({
|
||||
locator,
|
||||
slug: 'foo-bar',
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
});
|
||||
const shortUrl2 = await client.resolve('foo-bar');
|
||||
|
||||
expect(shortUrl2.data).toMatchObject(shortUrl1.data);
|
||||
});
|
||||
|
||||
test('cannot create short URL with the same slug', async () => {
|
||||
const { client, locator } = setup();
|
||||
await client.create({
|
||||
locator,
|
||||
slug: 'lala',
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.create({
|
||||
locator,
|
||||
slug: 'lala',
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
})
|
||||
).rejects.toThrowError(new Error(`Slug "lala" already exists.`));
|
||||
});
|
||||
|
||||
test('can automatically generate human-readable slug', async () => {
|
||||
const { client, locator } = setup();
|
||||
const shortUrl = await client.create({
|
||||
locator,
|
||||
humanReadableSlug: true,
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
});
|
||||
|
||||
expect(shortUrl.data.slug.split('-').length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.get()', () => {
|
||||
test('can fetch created short URL', async () => {
|
||||
const { client, locator } = setup();
|
||||
const shortUrl1 = await client.create({
|
||||
locator,
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
});
|
||||
const shortUrl2 = await client.get(shortUrl1.data.id);
|
||||
|
||||
expect(shortUrl2.data).toMatchObject(shortUrl1.data);
|
||||
});
|
||||
|
||||
test('throws when fetching non-existing short URL', async () => {
|
||||
const { client } = setup();
|
||||
|
||||
await expect(() => client.get('xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')).rejects.toThrowError(
|
||||
new Error(`No short url with id "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"`)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.delete()', () => {
|
||||
test('can delete an existing short URL', async () => {
|
||||
const { client, locator } = setup();
|
||||
const shortUrl1 = await client.create({
|
||||
locator,
|
||||
params: {
|
||||
url: '/app/test#foo/bar/baz',
|
||||
},
|
||||
});
|
||||
await client.delete(shortUrl1.data.id);
|
||||
|
||||
await expect(() => client.get(shortUrl1.data.id)).rejects.toThrowError(
|
||||
new Error(`No short url with id "${shortUrl1.data.id}"`)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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 { generateSlug } from 'random-word-slugs';
|
||||
import type { IShortUrlClient, ShortUrl, ShortUrlCreateParams } from '../../../common/url_service';
|
||||
import type { ShortUrlStorage } from './types';
|
||||
import { validateSlug } from './util';
|
||||
|
||||
const defaultAlphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
|
||||
function randomStr(length: number, alphabet = defaultAlphabet) {
|
||||
let str = '';
|
||||
const alphabetLength = alphabet.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
str += alphabet.charAt(Math.floor(Math.random() * alphabetLength));
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dependencies of the Short URL Client.
|
||||
*/
|
||||
export interface ServerShortUrlClientDependencies {
|
||||
/**
|
||||
* Current version of Kibana, e.g. 7.15.0.
|
||||
*/
|
||||
currentVersion: string;
|
||||
|
||||
/**
|
||||
* Storage provider for short URLs.
|
||||
*/
|
||||
storage: ShortUrlStorage;
|
||||
}
|
||||
|
||||
export class ServerShortUrlClient implements IShortUrlClient {
|
||||
constructor(private readonly dependencies: ServerShortUrlClientDependencies) {}
|
||||
|
||||
public async create<P extends SerializableRecord>({
|
||||
locator,
|
||||
params,
|
||||
slug = '',
|
||||
humanReadableSlug = false,
|
||||
}: ShortUrlCreateParams<P>): Promise<ShortUrl<P>> {
|
||||
if (slug) {
|
||||
validateSlug(slug);
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
slug = humanReadableSlug ? generateSlug() : randomStr(4);
|
||||
}
|
||||
|
||||
const { storage, currentVersion } = this.dependencies;
|
||||
|
||||
if (slug) {
|
||||
const isSlugTaken = await storage.exists(slug);
|
||||
if (isSlugTaken) {
|
||||
throw new Error(`Slug "${slug}" already exists.`);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const data = await storage.create({
|
||||
accessCount: 0,
|
||||
accessDate: now,
|
||||
createDate: now,
|
||||
slug,
|
||||
locator: {
|
||||
id: locator.id,
|
||||
version: currentVersion,
|
||||
state: params,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
public async get(id: string): Promise<ShortUrl> {
|
||||
const { storage } = this.dependencies;
|
||||
const data = await storage.getById(id);
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
const { storage } = this.dependencies;
|
||||
await storage.delete(id);
|
||||
}
|
||||
|
||||
public async resolve(slug: string): Promise<ShortUrl> {
|
||||
const { storage } = this.dependencies;
|
||||
const data = await storage.getBySlug(slug);
|
||||
|
||||
return {
|
||||
data,
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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 { SavedObjectsClientContract } from 'kibana/server';
|
||||
import { ShortUrlStorage } from './types';
|
||||
import type { IShortUrlClientFactory } from '../../../common/url_service';
|
||||
import { ServerShortUrlClient } from './short_url_client';
|
||||
import { SavedObjectShortUrlStorage } from './storage/saved_object_short_url_storage';
|
||||
|
||||
/**
|
||||
* Dependencies of the Short URL Client factory.
|
||||
*/
|
||||
export interface ServerShortUrlClientFactoryDependencies {
|
||||
/**
|
||||
* Current version of Kibana, e.g. 7.15.0.
|
||||
*/
|
||||
currentVersion: string;
|
||||
}
|
||||
|
||||
export interface ServerShortUrlClientFactoryCreateParams {
|
||||
savedObjects?: SavedObjectsClientContract;
|
||||
storage?: ShortUrlStorage;
|
||||
}
|
||||
|
||||
export class ServerShortUrlClientFactory
|
||||
implements IShortUrlClientFactory<ServerShortUrlClientFactoryCreateParams>
|
||||
{
|
||||
constructor(private readonly dependencies: ServerShortUrlClientFactoryDependencies) {}
|
||||
|
||||
public get(params: ServerShortUrlClientFactoryCreateParams): ServerShortUrlClient {
|
||||
const storage =
|
||||
params.storage ??
|
||||
new SavedObjectShortUrlStorage({
|
||||
savedObjects: params.savedObjects!,
|
||||
savedObjectType: 'url',
|
||||
});
|
||||
const client = new ServerShortUrlClient({
|
||||
storage,
|
||||
currentVersion: this.dependencies.currentVersion,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
* 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 { of } from 'src/plugins/kibana_utils/common';
|
||||
import { MemoryShortUrlStorage } from './memory_short_url_storage';
|
||||
|
||||
describe('.create()', () => {
|
||||
test('can create a new short URL', async () => {
|
||||
const storage = new MemoryShortUrlStorage();
|
||||
const now = Date.now();
|
||||
const url1 = await storage.create({
|
||||
accessCount: 0,
|
||||
createDate: now,
|
||||
accessDate: now,
|
||||
locator: {
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
slug: 'test-slug',
|
||||
});
|
||||
|
||||
expect(url1.accessCount).toBe(0);
|
||||
expect(url1.createDate).toBe(now);
|
||||
expect(url1.accessDate).toBe(now);
|
||||
expect(url1.slug).toBe('test-slug');
|
||||
expect(url1.locator).toEqual({
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('.getById()', () => {
|
||||
test('can fetch by ID a newly created short URL', async () => {
|
||||
const storage = new MemoryShortUrlStorage();
|
||||
const now = Date.now();
|
||||
const url1 = await storage.create({
|
||||
accessCount: 0,
|
||||
createDate: now,
|
||||
accessDate: now,
|
||||
locator: {
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
slug: 'test-slug',
|
||||
});
|
||||
const url2 = await storage.getById(url1.id);
|
||||
|
||||
expect(url2.accessCount).toBe(0);
|
||||
expect(url1.createDate).toBe(now);
|
||||
expect(url1.accessDate).toBe(now);
|
||||
expect(url2.slug).toBe('test-slug');
|
||||
expect(url2.locator).toEqual({
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('throws when URL does not exist', async () => {
|
||||
const storage = new MemoryShortUrlStorage();
|
||||
const now = Date.now();
|
||||
await storage.create({
|
||||
accessCount: 0,
|
||||
createDate: now,
|
||||
accessDate: now,
|
||||
locator: {
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
slug: 'test-slug',
|
||||
});
|
||||
const [, error] = await of(storage.getById('DOES_NOT_EXIST'));
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.getBySlug()', () => {
|
||||
test('can fetch by slug a newly created short URL', async () => {
|
||||
const storage = new MemoryShortUrlStorage();
|
||||
const now = Date.now();
|
||||
const url1 = await storage.create({
|
||||
accessCount: 0,
|
||||
createDate: now,
|
||||
accessDate: now,
|
||||
locator: {
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
slug: 'test-slug',
|
||||
});
|
||||
const url2 = await storage.getBySlug('test-slug');
|
||||
|
||||
expect(url2.accessCount).toBe(0);
|
||||
expect(url1.createDate).toBe(now);
|
||||
expect(url1.accessDate).toBe(now);
|
||||
expect(url2.slug).toBe('test-slug');
|
||||
expect(url2.locator).toEqual({
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('throws when URL does not exist', async () => {
|
||||
const storage = new MemoryShortUrlStorage();
|
||||
const now = Date.now();
|
||||
await storage.create({
|
||||
accessCount: 0,
|
||||
createDate: now,
|
||||
accessDate: now,
|
||||
locator: {
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
slug: 'test-slug',
|
||||
});
|
||||
const [, error] = await of(storage.getBySlug('DOES_NOT_EXIST'));
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.delete()', () => {
|
||||
test('can delete a newly created URL', async () => {
|
||||
const storage = new MemoryShortUrlStorage();
|
||||
const now = Date.now();
|
||||
const url1 = await storage.create({
|
||||
accessCount: 0,
|
||||
createDate: now,
|
||||
accessDate: now,
|
||||
locator: {
|
||||
id: 'TEST_LOCATOR',
|
||||
version: '7.11',
|
||||
state: {
|
||||
foo: 'bar',
|
||||
},
|
||||
},
|
||||
slug: 'test-slug',
|
||||
});
|
||||
|
||||
const [, error1] = await of(storage.getById(url1.id));
|
||||
await storage.delete(url1.id);
|
||||
const [, error2] = await of(storage.getById(url1.id));
|
||||
|
||||
expect(error1).toBe(undefined);
|
||||
expect(error2).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
* 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 { v4 as uuidv4 } from 'uuid';
|
||||
import type { SerializableRecord } from '@kbn/utility-types';
|
||||
import { ShortUrlData } from 'src/plugins/share/common/url_service/short_urls/types';
|
||||
import { ShortUrlStorage } from '../types';
|
||||
|
||||
export class MemoryShortUrlStorage implements ShortUrlStorage {
|
||||
private urls = new Map<string, ShortUrlData>();
|
||||
|
||||
public async create<P extends SerializableRecord = SerializableRecord>(
|
||||
data: Omit<ShortUrlData<P>, 'id'>
|
||||
): Promise<ShortUrlData<P>> {
|
||||
const id = uuidv4();
|
||||
const url: ShortUrlData<P> = { ...data, id };
|
||||
this.urls.set(id, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
public async getById<P extends SerializableRecord = SerializableRecord>(
|
||||
id: string
|
||||
): Promise<ShortUrlData<P>> {
|
||||
if (!this.urls.has(id)) {
|
||||
throw new Error(`No short url with id "${id}"`);
|
||||
}
|
||||
return this.urls.get(id)! as ShortUrlData<P>;
|
||||
}
|
||||
|
||||
public async getBySlug<P extends SerializableRecord = SerializableRecord>(
|
||||
slug: string
|
||||
): Promise<ShortUrlData<P>> {
|
||||
for (const url of this.urls.values()) {
|
||||
if (url.slug === slug) {
|
||||
return url as ShortUrlData<P>;
|
||||
}
|
||||
}
|
||||
throw new Error(`No short url with slug "${slug}".`);
|
||||
}
|
||||
|
||||
public async exists(slug: string): Promise<boolean> {
|
||||
for (const url of this.urls.values()) {
|
||||
if (url.slug === slug) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
this.urls.delete(id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
/*
|
||||
* 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 { SavedObject, SavedObjectsClientContract } from 'kibana/server';
|
||||
import { LEGACY_SHORT_URL_LOCATOR_ID } from '../../../../common/url_service/locators/legacy_short_url_locator';
|
||||
import { ShortUrlData } from '../../../../common/url_service/short_urls/types';
|
||||
import { ShortUrlStorage } from '../types';
|
||||
import { escapeSearchReservedChars } from '../util';
|
||||
|
||||
export type ShortUrlSavedObject = SavedObject<ShortUrlSavedObjectAttributes>;
|
||||
|
||||
/**
|
||||
* Fields that stored in the short url saved object.
|
||||
*/
|
||||
export interface ShortUrlSavedObjectAttributes {
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* Serialized locator state.
|
||||
*/
|
||||
readonly locatorJSON: string;
|
||||
|
||||
/**
|
||||
* Legacy field - was used in old short URL versions. This field will
|
||||
* be removed in the future by a migration.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
readonly url: string;
|
||||
}
|
||||
|
||||
const createShortUrlData = <P extends SerializableRecord = SerializableRecord>(
|
||||
savedObject: ShortUrlSavedObject
|
||||
): ShortUrlData<P> => {
|
||||
const attributes = savedObject.attributes;
|
||||
|
||||
if (!!attributes.url) {
|
||||
const { url, ...rest } = attributes;
|
||||
const state = { url } as unknown as P;
|
||||
|
||||
return {
|
||||
id: savedObject.id,
|
||||
slug: savedObject.id,
|
||||
locator: {
|
||||
id: LEGACY_SHORT_URL_LOCATOR_ID,
|
||||
version: '7.15.0',
|
||||
state,
|
||||
},
|
||||
...rest,
|
||||
} as ShortUrlData<P>;
|
||||
}
|
||||
|
||||
const { locatorJSON, ...rest } = attributes;
|
||||
const locator = JSON.parse(locatorJSON) as ShortUrlData<P>['locator'];
|
||||
|
||||
return {
|
||||
id: savedObject.id,
|
||||
locator,
|
||||
...rest,
|
||||
} as ShortUrlData<P>;
|
||||
};
|
||||
|
||||
const createAttributes = <P extends SerializableRecord = SerializableRecord>(
|
||||
data: Omit<ShortUrlData<P>, 'id'>
|
||||
): ShortUrlSavedObjectAttributes => {
|
||||
const { locator, ...rest } = data;
|
||||
const attributes: ShortUrlSavedObjectAttributes = {
|
||||
...rest,
|
||||
locatorJSON: JSON.stringify(locator),
|
||||
url: '',
|
||||
};
|
||||
|
||||
return attributes;
|
||||
};
|
||||
|
||||
export interface SavedObjectShortUrlStorageDependencies {
|
||||
savedObjectType: string;
|
||||
savedObjects: SavedObjectsClientContract;
|
||||
}
|
||||
|
||||
export class SavedObjectShortUrlStorage implements ShortUrlStorage {
|
||||
constructor(private readonly dependencies: SavedObjectShortUrlStorageDependencies) {}
|
||||
|
||||
public async create<P extends SerializableRecord = SerializableRecord>(
|
||||
data: Omit<ShortUrlData<P>, 'id'>
|
||||
): Promise<ShortUrlData<P>> {
|
||||
const { savedObjects, savedObjectType } = this.dependencies;
|
||||
const attributes = createAttributes(data);
|
||||
|
||||
const savedObject = await savedObjects.create(savedObjectType, attributes, {
|
||||
refresh: true,
|
||||
});
|
||||
|
||||
return createShortUrlData<P>(savedObject);
|
||||
}
|
||||
|
||||
public async getById<P extends SerializableRecord = SerializableRecord>(
|
||||
id: string
|
||||
): Promise<ShortUrlData<P>> {
|
||||
const { savedObjects, savedObjectType } = this.dependencies;
|
||||
const savedObject = await savedObjects.get<ShortUrlSavedObjectAttributes>(savedObjectType, id);
|
||||
|
||||
return createShortUrlData<P>(savedObject);
|
||||
}
|
||||
|
||||
public async getBySlug<P extends SerializableRecord = SerializableRecord>(
|
||||
slug: string
|
||||
): Promise<ShortUrlData<P>> {
|
||||
const { savedObjects } = this.dependencies;
|
||||
const search = `(attributes.slug:"${escapeSearchReservedChars(slug)}")`;
|
||||
const result = await savedObjects.find({
|
||||
type: this.dependencies.savedObjectType,
|
||||
search,
|
||||
});
|
||||
|
||||
if (result.saved_objects.length !== 1) {
|
||||
throw new Error('not found');
|
||||
}
|
||||
|
||||
const savedObject = result.saved_objects[0] as ShortUrlSavedObject;
|
||||
|
||||
return createShortUrlData<P>(savedObject);
|
||||
}
|
||||
|
||||
public async exists(slug: string): Promise<boolean> {
|
||||
const { savedObjects } = this.dependencies;
|
||||
const search = `(attributes.slug:"${escapeSearchReservedChars(slug)}")`;
|
||||
const result = await savedObjects.find({
|
||||
type: this.dependencies.savedObjectType,
|
||||
search,
|
||||
});
|
||||
|
||||
return result.saved_objects.length > 0;
|
||||
}
|
||||
|
||||
public async delete(id: string): Promise<void> {
|
||||
const { savedObjects, savedObjectType } = this.dependencies;
|
||||
await savedObjects.delete(savedObjectType, id);
|
||||
}
|
||||
}
|
44
src/plugins/share/server/url_service/short_urls/types.ts
Normal file
44
src/plugins/share/server/url_service/short_urls/types.ts
Normal 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 type { SerializableRecord } from '@kbn/utility-types';
|
||||
import { ShortUrlData } from '../../../common/url_service/short_urls/types';
|
||||
|
||||
/**
|
||||
* Interface used for persisting short URLs.
|
||||
*/
|
||||
export interface ShortUrlStorage {
|
||||
/**
|
||||
* Create and store a new short URL entry.
|
||||
*/
|
||||
create<P extends SerializableRecord = SerializableRecord>(
|
||||
data: Omit<ShortUrlData<P>, 'id'>
|
||||
): Promise<ShortUrlData<P>>;
|
||||
|
||||
/**
|
||||
* Fetch a short URL entry by ID.
|
||||
*/
|
||||
getById<P extends SerializableRecord = SerializableRecord>(id: string): Promise<ShortUrlData<P>>;
|
||||
|
||||
/**
|
||||
* Fetch a short URL entry by slug.
|
||||
*/
|
||||
getBySlug<P extends SerializableRecord = SerializableRecord>(
|
||||
slug: string
|
||||
): Promise<ShortUrlData<P>>;
|
||||
|
||||
/**
|
||||
* Checks if a short URL exists by slug.
|
||||
*/
|
||||
exists(slug: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Delete an existing short URL entry.
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
}
|
69
src/plugins/share/server/url_service/short_urls/util.test.ts
Normal file
69
src/plugins/share/server/url_service/short_urls/util.test.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* 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 { escapeSearchReservedChars, validateSlug } from './util';
|
||||
|
||||
describe('escapeSearchReservedChars', () => {
|
||||
it('should escape search reserved chars', () => {
|
||||
expect(escapeSearchReservedChars('+')).toEqual('\\+');
|
||||
expect(escapeSearchReservedChars('-')).toEqual('\\-');
|
||||
expect(escapeSearchReservedChars('!')).toEqual('\\!');
|
||||
expect(escapeSearchReservedChars('(')).toEqual('\\(');
|
||||
expect(escapeSearchReservedChars(')')).toEqual('\\)');
|
||||
expect(escapeSearchReservedChars('*')).toEqual('\\*');
|
||||
expect(escapeSearchReservedChars('~')).toEqual('\\~');
|
||||
expect(escapeSearchReservedChars('^')).toEqual('\\^');
|
||||
expect(escapeSearchReservedChars('|')).toEqual('\\|');
|
||||
expect(escapeSearchReservedChars('[')).toEqual('\\[');
|
||||
expect(escapeSearchReservedChars(']')).toEqual('\\]');
|
||||
expect(escapeSearchReservedChars('{')).toEqual('\\{');
|
||||
expect(escapeSearchReservedChars('}')).toEqual('\\}');
|
||||
expect(escapeSearchReservedChars('"')).toEqual('\\"');
|
||||
});
|
||||
|
||||
it('escapes short URL slugs', () => {
|
||||
expect(escapeSearchReservedChars('test-slug-123456789')).toEqual('test\\-slug\\-123456789');
|
||||
expect(escapeSearchReservedChars('my-dashboard-link')).toEqual('my\\-dashboard\\-link');
|
||||
expect(escapeSearchReservedChars('link-v1.0.0')).toEqual('link\\-v1.0.0');
|
||||
expect(escapeSearchReservedChars('simple_link')).toEqual('simple_link');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateSlug', () => {
|
||||
it('validates slugs that contain [a-zA-Z0-9.-_] chars', () => {
|
||||
validateSlug('asdf');
|
||||
validateSlug('asdf-asdf');
|
||||
validateSlug('asdf-asdf-333');
|
||||
validateSlug('my-custom-slug');
|
||||
validateSlug('my.slug');
|
||||
validateSlug('my_super-custom.slug');
|
||||
});
|
||||
|
||||
it('throws on slugs which contain invalid characters', () => {
|
||||
expect(() => validateSlug('hello-tom&herry')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid [slug = hello-tom&herry]."`
|
||||
);
|
||||
expect(() => validateSlug('foo(bar)')).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid [slug = foo(bar)]."`
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if slug is shorter than 3 chars', () => {
|
||||
expect(() => validateSlug('ab')).toThrowErrorMatchingInlineSnapshot(`"Invalid [slug = ab]."`);
|
||||
});
|
||||
|
||||
it('throws if slug is longer than 255 chars', () => {
|
||||
expect(() =>
|
||||
validateSlug(
|
||||
'aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa'
|
||||
)
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Invalid [slug = aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa-aaaaaaaaaa]."`
|
||||
);
|
||||
});
|
||||
});
|
25
src/plugins/share/server/url_service/short_urls/util.ts
Normal file
25
src/plugins/share/server/url_service/short_urls/util.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This function escapes reserved characters as listed here:
|
||||
* https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html#_reserved_characters
|
||||
*/
|
||||
export const escapeSearchReservedChars = (str: string) => {
|
||||
return str.replace(/[-=&|!{}()\[\]^"~*?:\\\/\+]+/g, '\\$&');
|
||||
};
|
||||
|
||||
/**
|
||||
* Allows only characters in slug that can appear as a part of a URL.
|
||||
*/
|
||||
export const validateSlug = (slug: string) => {
|
||||
const regex = /^[a-zA-Z0-9\.\-\_]{3,255}$/;
|
||||
if (!regex.test(slug)) {
|
||||
throw new Error(`Invalid [slug = ${slug}].`);
|
||||
}
|
||||
};
|
12
src/plugins/share/server/url_service/types.ts
Normal file
12
src/plugins/share/server/url_service/types.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
/*
|
||||
* 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 { UrlService } from '../../common/url_service';
|
||||
import { ServerShortUrlClientFactoryCreateParams } from './short_urls';
|
||||
|
||||
export type ServerUrlService = UrlService<ServerShortUrlClientFactoryCreateParams>;
|
|
@ -22,7 +22,7 @@ export default function ({ loadTestFile }: FtrProviderContext) {
|
|||
loadTestFile(require.resolve('./saved_objects'));
|
||||
loadTestFile(require.resolve('./scripts'));
|
||||
loadTestFile(require.resolve('./search'));
|
||||
loadTestFile(require.resolve('./shorten'));
|
||||
loadTestFile(require.resolve('./short_url'));
|
||||
loadTestFile(require.resolve('./suggestions'));
|
||||
loadTestFile(require.resolve('./status'));
|
||||
loadTestFile(require.resolve('./stats'));
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('create_short_url', () => {
|
||||
loadTestFile(require.resolve('./validation'));
|
||||
loadTestFile(require.resolve('./main'));
|
||||
});
|
||||
}
|
139
test/api_integration/apis/short_url/create_short_url/main.ts
Normal file
139
test/api_integration/apis/short_url/create_short_url/main.ts
Normal file
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('main', () => {
|
||||
it('can create a short URL with just locator data', async () => {
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
expect(typeof response.body).to.be('object');
|
||||
expect(typeof response.body.id).to.be('string');
|
||||
expect(typeof response.body.locator).to.be('object');
|
||||
expect(response.body.locator.id).to.be('LEGACY_SHORT_URL_LOCATOR');
|
||||
expect(typeof response.body.locator.version).to.be('string');
|
||||
expect(response.body.locator.state).to.eql({});
|
||||
expect(response.body.accessCount).to.be(0);
|
||||
expect(typeof response.body.accessDate).to.be('number');
|
||||
expect(typeof response.body.createDate).to.be('number');
|
||||
expect(typeof response.body.slug).to.be('string');
|
||||
expect(response.body.url).to.be('');
|
||||
});
|
||||
|
||||
it('can create a short URL with locator params', async () => {
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {
|
||||
url: '/foo/bar',
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
expect(typeof response.body).to.be('object');
|
||||
expect(typeof response.body.id).to.be('string');
|
||||
expect(typeof response.body.locator).to.be('object');
|
||||
expect(response.body.locator.id).to.be('LEGACY_SHORT_URL_LOCATOR');
|
||||
expect(typeof response.body.locator.version).to.be('string');
|
||||
expect(response.body.locator.state).to.eql({
|
||||
url: '/foo/bar',
|
||||
});
|
||||
expect(response.body.accessCount).to.be(0);
|
||||
expect(typeof response.body.accessDate).to.be('number');
|
||||
expect(typeof response.body.createDate).to.be('number');
|
||||
expect(typeof response.body.slug).to.be('string');
|
||||
expect(response.body.url).to.be('');
|
||||
});
|
||||
|
||||
describe('short_url slugs', () => {
|
||||
it('generates at least 4 character slug by default', async () => {
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
expect(typeof response.body.slug).to.be('string');
|
||||
expect(response.body.slug.length > 3).to.be(true);
|
||||
expect(response.body.url).to.be('');
|
||||
});
|
||||
|
||||
it('can generate a human-readable slug, composed of three words', async () => {
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {},
|
||||
humanReadableSlug: true,
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
expect(typeof response.body.slug).to.be('string');
|
||||
const words = response.body.slug.split('-');
|
||||
expect(words.length).to.be(3);
|
||||
for (const word of words) {
|
||||
expect(word.length > 0).to.be(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('can create a short URL with custom slug', async () => {
|
||||
const rnd = Math.round(Math.random() * 1e6) + 1;
|
||||
const slug = 'test-slug-' + Date.now() + '-' + rnd;
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {
|
||||
url: '/foo/bar',
|
||||
},
|
||||
slug,
|
||||
});
|
||||
|
||||
expect(response.status).to.be(200);
|
||||
expect(typeof response.body).to.be('object');
|
||||
expect(typeof response.body.id).to.be('string');
|
||||
expect(typeof response.body.locator).to.be('object');
|
||||
expect(response.body.locator.id).to.be('LEGACY_SHORT_URL_LOCATOR');
|
||||
expect(typeof response.body.locator.version).to.be('string');
|
||||
expect(response.body.locator.state).to.eql({
|
||||
url: '/foo/bar',
|
||||
});
|
||||
expect(response.body.accessCount).to.be(0);
|
||||
expect(typeof response.body.accessDate).to.be('number');
|
||||
expect(typeof response.body.createDate).to.be('number');
|
||||
expect(response.body.slug).to.be(slug);
|
||||
expect(response.body.url).to.be('');
|
||||
});
|
||||
|
||||
it('cannot create a short URL with the same slug', async () => {
|
||||
const rnd = Math.round(Math.random() * 1e6) + 1;
|
||||
const slug = 'test-slug-' + Date.now() + '-' + rnd;
|
||||
const response1 = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {
|
||||
url: '/foo/bar',
|
||||
},
|
||||
slug,
|
||||
});
|
||||
const response2 = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {
|
||||
url: '/foo/bar',
|
||||
},
|
||||
slug,
|
||||
});
|
||||
|
||||
expect(response1.status === 200).to.be(true);
|
||||
expect(response2.status >= 400).to.be(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('validation', () => {
|
||||
it('returns error when no data is provided in POST payload', async () => {
|
||||
const response = await supertest.post('/api/short_url');
|
||||
|
||||
expect(response.status).to.be(400);
|
||||
expect(response.body.statusCode).to.be(400);
|
||||
expect(response.body.message).to.be(
|
||||
'[request body]: expected a plain object value, but found [null] instead.'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns error when locator ID is not provided', async () => {
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(400);
|
||||
});
|
||||
|
||||
it('returns error when locator is not found', async () => {
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR-NOT_FOUND',
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(response.status).to.be(409);
|
||||
expect(response.body.statusCode).to.be(409);
|
||||
expect(response.body.error).to.be('Conflict');
|
||||
expect(response.body.message).to.be('Locator not found.');
|
||||
});
|
||||
|
||||
it('returns error when slug is too short', async () => {
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {},
|
||||
slug: 'a',
|
||||
});
|
||||
|
||||
expect(response.status).to.be(400);
|
||||
});
|
||||
|
||||
it('returns error on invalid character in slug', async () => {
|
||||
const response = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {
|
||||
url: '/foo/bar',
|
||||
},
|
||||
slug: 'pipe|is-not-allowed',
|
||||
});
|
||||
|
||||
expect(response.status >= 400).to.be(true);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('delete_short_url', () => {
|
||||
loadTestFile(require.resolve('./validation'));
|
||||
loadTestFile(require.resolve('./main'));
|
||||
});
|
||||
}
|
56
test/api_integration/apis/short_url/delete_short_url/main.ts
Normal file
56
test/api_integration/apis/short_url/delete_short_url/main.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('main', () => {
|
||||
it('can delete a short URL', async () => {
|
||||
const response1 = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {},
|
||||
});
|
||||
const response2 = await supertest.get('/api/short_url/' + response1.body.id);
|
||||
|
||||
expect(response2.body).to.eql(response1.body);
|
||||
|
||||
const response3 = await supertest.delete('/api/short_url/' + response1.body.id);
|
||||
|
||||
expect(response3.status).to.eql(200);
|
||||
expect(response3.body).to.eql(null);
|
||||
|
||||
const response4 = await supertest.get('/api/short_url/' + response1.body.id);
|
||||
|
||||
expect(response4.status).to.eql(404);
|
||||
});
|
||||
|
||||
it('returns 404 when deleting already deleted short URL', async () => {
|
||||
const response1 = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {},
|
||||
});
|
||||
|
||||
const response3 = await supertest.delete('/api/short_url/' + response1.body.id);
|
||||
|
||||
expect(response3.status).to.eql(200);
|
||||
|
||||
const response4 = await supertest.delete('/api/short_url/' + response1.body.id);
|
||||
|
||||
expect(response4.status).to.eql(404);
|
||||
});
|
||||
|
||||
it('returns 404 when deleting a non-existing model', async () => {
|
||||
const response = await supertest.delete('/api/short_url/' + 'non-existing-id');
|
||||
|
||||
expect(response.status).to.eql(404);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('validation', () => {
|
||||
it('errors when short URL ID is too short', async () => {
|
||||
const response = await supertest.delete('/api/short_url/ab');
|
||||
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'[request params.id]: value has length [2] but it must have a minimum length of [4].',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when short URL ID is too long', async () => {
|
||||
const response = await supertest.delete(
|
||||
'/api/short_url/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij'
|
||||
);
|
||||
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'[request params.id]: value has length [130] but it must have a maximum length of [128].',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
16
test/api_integration/apis/short_url/get_short_url/index.ts
Normal file
16
test/api_integration/apis/short_url/get_short_url/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('get_short_url', () => {
|
||||
loadTestFile(require.resolve('./validation'));
|
||||
loadTestFile(require.resolve('./main'));
|
||||
});
|
||||
}
|
51
test/api_integration/apis/short_url/get_short_url/main.ts
Normal file
51
test/api_integration/apis/short_url/get_short_url/main.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('main', () => {
|
||||
it('can fetch a newly created short URL', async () => {
|
||||
const response1 = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {},
|
||||
});
|
||||
const response2 = await supertest.get('/api/short_url/' + response1.body.id);
|
||||
|
||||
expect(response2.body).to.eql(response1.body);
|
||||
});
|
||||
|
||||
it('supports legacy short URLs', async () => {
|
||||
const id = 'abcdefghjabcdefghjabcdefghjabcdefghj';
|
||||
await supertest.post('/api/saved_objects/url/' + id).send({
|
||||
attributes: {
|
||||
accessCount: 25,
|
||||
accessDate: 1632672537546,
|
||||
createDate: 1632672507685,
|
||||
url: '/app/dashboards#/view/123',
|
||||
},
|
||||
});
|
||||
const response = await supertest.get('/api/short_url/' + id);
|
||||
await supertest.delete('/api/saved_objects/url/' + id).send();
|
||||
|
||||
expect(response.body.id).to.be(id);
|
||||
expect(response.body.slug).to.be(id);
|
||||
expect(response.body.locator).to.eql({
|
||||
id: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
version: '7.15.0',
|
||||
state: { url: '/app/dashboards#/view/123' },
|
||||
});
|
||||
expect(response.body.accessCount).to.be(25);
|
||||
expect(response.body.accessDate).to.be(1632672537546);
|
||||
expect(response.body.createDate).to.be(1632672507685);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('validation', () => {
|
||||
it('errors when short URL ID is too short', async () => {
|
||||
const response = await supertest.get('/api/short_url/ab');
|
||||
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'[request params.id]: value has length [2] but it must have a minimum length of [4].',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when short URL ID is too long', async () => {
|
||||
const response = await supertest.get(
|
||||
'/api/short_url/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij'
|
||||
);
|
||||
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'[request params.id]: value has length [130] but it must have a maximum length of [128].',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
18
test/api_integration/apis/short_url/index.ts
Normal file
18
test/api_integration/apis/short_url/index.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('short_url', () => {
|
||||
loadTestFile(require.resolve('./create_short_url'));
|
||||
loadTestFile(require.resolve('./get_short_url'));
|
||||
loadTestFile(require.resolve('./delete_short_url'));
|
||||
loadTestFile(require.resolve('./resolve_short_url'));
|
||||
});
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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 { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ loadTestFile }: FtrProviderContext) {
|
||||
describe('resolve_short_url', () => {
|
||||
loadTestFile(require.resolve('./validation'));
|
||||
loadTestFile(require.resolve('./main'));
|
||||
});
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('main', () => {
|
||||
it('can resolve a short URL by its slug', async () => {
|
||||
const rnd = Math.round(Math.random() * 1e6) + 1;
|
||||
const slug = 'test-slug-' + Date.now() + '-' + rnd;
|
||||
const response1 = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {},
|
||||
slug,
|
||||
});
|
||||
const response2 = await supertest.get('/api/short_url/_slug/' + slug);
|
||||
|
||||
expect(response2.body).to.eql(response1.body);
|
||||
});
|
||||
|
||||
it('can resolve a short URL by its slug, when slugs are similar', async () => {
|
||||
const rnd = Math.round(Math.random() * 1e6) + 1;
|
||||
const now = Date.now();
|
||||
const slug1 = 'test-slug-' + now + '-' + rnd + '.1';
|
||||
const slug2 = 'test-slug-' + now + '-' + rnd + '.2';
|
||||
const response1 = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {
|
||||
url: '/path1',
|
||||
},
|
||||
slug: slug1,
|
||||
});
|
||||
const response2 = await supertest.post('/api/short_url').send({
|
||||
locatorId: 'LEGACY_SHORT_URL_LOCATOR',
|
||||
params: {
|
||||
url: '/path2',
|
||||
},
|
||||
slug: slug2,
|
||||
});
|
||||
const response3 = await supertest.get('/api/short_url/_slug/' + slug1);
|
||||
const response4 = await supertest.get('/api/short_url/_slug/' + slug2);
|
||||
|
||||
expect(response1.body).to.eql(response3.body);
|
||||
expect(response2.body).to.eql(response4.body);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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 expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../../ftr_provider_context';
|
||||
|
||||
export default function ({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertest');
|
||||
|
||||
describe('validation', () => {
|
||||
it('errors when short URL slug is too short', async () => {
|
||||
const response = await supertest.get('/api/short_url/_slug/aa');
|
||||
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'[request params.slug]: value has length [2] but it must have a minimum length of [4].',
|
||||
});
|
||||
});
|
||||
|
||||
it('errors when short URL ID is too long', async () => {
|
||||
const response = await supertest.get(
|
||||
'/api/short_url/_slug/abcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghijabcdefghij'
|
||||
);
|
||||
|
||||
expect(response.body).to.eql({
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message:
|
||||
'[request params.slug]: value has length [130] but it must have a maximum length of [128].',
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,52 +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 expect from '@kbn/expect';
|
||||
|
||||
export default function ({ getService }) {
|
||||
const supertest = getService('supertest');
|
||||
const kibanaServer = getService('kibanaServer');
|
||||
|
||||
describe('url shortener', () => {
|
||||
before(async () => {
|
||||
await kibanaServer.importExport.load(
|
||||
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
|
||||
);
|
||||
});
|
||||
after(async () => {
|
||||
await kibanaServer.importExport.unload(
|
||||
'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json'
|
||||
);
|
||||
});
|
||||
|
||||
it('generates shortened urls', async () => {
|
||||
const resp = await supertest
|
||||
.post('/api/shorten_url')
|
||||
.set('content-type', 'application/json')
|
||||
.send({ url: '/app/visualize#/create' })
|
||||
.expect(200);
|
||||
|
||||
expect(resp.body).to.have.property('urlId');
|
||||
expect(typeof resp.body.urlId).to.be('string');
|
||||
expect(resp.body.urlId.length > 0).to.be(true);
|
||||
});
|
||||
|
||||
it('redirects shortened urls', async () => {
|
||||
const resp = await supertest
|
||||
.post('/api/shorten_url')
|
||||
.set('content-type', 'application/json')
|
||||
.send({ url: '/app/visualize#/create' });
|
||||
|
||||
const urlId = resp.body.urlId;
|
||||
await supertest
|
||||
.get(`/goto/${urlId}`)
|
||||
.expect(302)
|
||||
.expect('location', '/app/visualize#/create');
|
||||
});
|
||||
});
|
||||
}
|
|
@ -89,7 +89,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
it('should allow for copying the snapshot URL as a short URL', async function () {
|
||||
const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$');
|
||||
const re = new RegExp(baseUrl + '/goto/.+$');
|
||||
await PageObjects.share.checkShortenUrl();
|
||||
await retry.try(async () => {
|
||||
const actualUrl = await PageObjects.share.getSharedUrl();
|
||||
|
@ -148,7 +148,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
it('should allow for copying the snapshot URL as a short URL and should open it', async function () {
|
||||
const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$');
|
||||
const re = new RegExp(baseUrl + '/goto/.+$');
|
||||
await PageObjects.share.checkShortenUrl();
|
||||
let actualUrl: string = '';
|
||||
await retry.try(async () => {
|
||||
|
|
|
@ -99,6 +99,7 @@ const urlService = new UrlService({
|
|||
getUrl: async ({ app, path }, { absolute }) => {
|
||||
return `${absolute ? 'http://localhost:8888' : ''}/app/${app}${path}`;
|
||||
},
|
||||
shortUrls: {} as any,
|
||||
});
|
||||
const locator = urlService.locators.create(new MlLocatorDefinition());
|
||||
|
||||
|
|
|
@ -27,7 +27,6 @@ export default function ({ loadTestFile }: FtrProviderContext) {
|
|||
loadTestFile(require.resolve('./uptime'));
|
||||
loadTestFile(require.resolve('./maps'));
|
||||
loadTestFile(require.resolve('./security_solution'));
|
||||
loadTestFile(require.resolve('./short_urls'));
|
||||
loadTestFile(require.resolve('./lens'));
|
||||
loadTestFile(require.resolve('./ml'));
|
||||
loadTestFile(require.resolve('./transform'));
|
||||
|
|
|
@ -1,197 +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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function featureControlsTests({ getService }: FtrProviderContext) {
|
||||
const supertest = getService('supertestWithoutAuth');
|
||||
const security = getService('security');
|
||||
|
||||
describe('feature controls', () => {
|
||||
const kibanaUsername = 'kibana_admin';
|
||||
const kibanaUserRoleName = 'kibana_admin';
|
||||
|
||||
const kibanaUserPassword = `${kibanaUsername}-password`;
|
||||
|
||||
let urlId: string;
|
||||
|
||||
// a sampling of features to test against
|
||||
const features = [
|
||||
{
|
||||
featureId: 'discover',
|
||||
canAccess: true,
|
||||
canCreate: true,
|
||||
},
|
||||
{
|
||||
featureId: 'dashboard',
|
||||
canAccess: true,
|
||||
canCreate: true,
|
||||
},
|
||||
{
|
||||
featureId: 'visualize',
|
||||
canAccess: true,
|
||||
canCreate: true,
|
||||
},
|
||||
{
|
||||
featureId: 'infrastructure',
|
||||
canAccess: true,
|
||||
canCreate: false,
|
||||
},
|
||||
{
|
||||
featureId: 'canvas',
|
||||
canAccess: true,
|
||||
canCreate: false,
|
||||
},
|
||||
{
|
||||
featureId: 'maps',
|
||||
canAccess: true,
|
||||
canCreate: false,
|
||||
},
|
||||
{
|
||||
featureId: 'unknown-feature',
|
||||
canAccess: false,
|
||||
canCreate: false,
|
||||
},
|
||||
];
|
||||
|
||||
before(async () => {
|
||||
for (const feature of features) {
|
||||
await security.role.create(`${feature.featureId}-role`, {
|
||||
kibana: [
|
||||
{
|
||||
base: [],
|
||||
feature: {
|
||||
[feature.featureId]: ['read'],
|
||||
},
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
});
|
||||
await security.role.create(`${feature.featureId}-minimal-role`, {
|
||||
kibana: [
|
||||
{
|
||||
base: [],
|
||||
feature: {
|
||||
[feature.featureId]: ['minimal_all'],
|
||||
},
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
});
|
||||
await security.role.create(`${feature.featureId}-minimal-shorten-role`, {
|
||||
kibana: [
|
||||
{
|
||||
base: [],
|
||||
feature: {
|
||||
[feature.featureId]: ['minimal_read', 'url_create'],
|
||||
},
|
||||
spaces: ['*'],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await security.user.create(`${feature.featureId}-user`, {
|
||||
password: kibanaUserPassword,
|
||||
roles: [`${feature.featureId}-role`],
|
||||
full_name: 'a kibana user',
|
||||
});
|
||||
|
||||
await security.user.create(`${feature.featureId}-minimal-user`, {
|
||||
password: kibanaUserPassword,
|
||||
roles: [`${feature.featureId}-minimal-role`],
|
||||
full_name: 'a kibana user',
|
||||
});
|
||||
|
||||
await security.user.create(`${feature.featureId}-minimal-shorten-user`, {
|
||||
password: kibanaUserPassword,
|
||||
roles: [`${feature.featureId}-minimal-shorten-role`],
|
||||
full_name: 'a kibana user',
|
||||
});
|
||||
}
|
||||
|
||||
await security.user.create(kibanaUsername, {
|
||||
password: kibanaUserPassword,
|
||||
roles: [kibanaUserRoleName],
|
||||
full_name: 'a kibana user',
|
||||
});
|
||||
|
||||
await supertest
|
||||
.post(`/api/shorten_url`)
|
||||
.auth(kibanaUsername, kibanaUserPassword)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ url: '/app/kibana#foo/bar/baz' })
|
||||
.then((resp: Record<string, any>) => {
|
||||
urlId = resp.body.urlId;
|
||||
});
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
const users = features.flatMap((feature) => [
|
||||
security.user.delete(`${feature.featureId}-user`),
|
||||
security.user.delete(`${feature.featureId}-minimal-user`),
|
||||
security.user.delete(`${feature.featureId}-minimal-shorten-user`),
|
||||
]);
|
||||
const roles = features.flatMap((feature) => [
|
||||
security.role.delete(`${feature.featureId}-role`),
|
||||
security.role.delete(`${feature.featureId}-minimal-role`),
|
||||
security.role.delete(`${feature.featureId}-minimal-shorten-role`),
|
||||
]);
|
||||
await Promise.all([...users, ...roles]);
|
||||
await security.user.delete(kibanaUsername);
|
||||
});
|
||||
|
||||
features.forEach((feature) => {
|
||||
it(`users with "read" access to ${feature.featureId} ${
|
||||
feature.canAccess ? 'should' : 'should not'
|
||||
} be able to access short-urls`, async () => {
|
||||
await supertest
|
||||
.get(`/goto/${urlId}`)
|
||||
.auth(`${feature.featureId}-user`, kibanaUserPassword)
|
||||
.then((resp: Record<string, any>) => {
|
||||
if (feature.canAccess) {
|
||||
expect(resp.status).to.eql(302);
|
||||
expect(resp.headers.location).to.eql('/app/kibana#foo/bar/baz');
|
||||
} else {
|
||||
expect(resp.status).to.eql(403);
|
||||
expect(resp.headers.location).to.eql(undefined);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it(`users with "minimal_all" access to ${feature.featureId} should not be able to create short-urls`, async () => {
|
||||
await supertest
|
||||
.post(`/api/shorten_url`)
|
||||
.auth(`${feature.featureId}-minimal-user`, kibanaUserPassword)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ url: '/app/dashboard' })
|
||||
.then((resp: Record<string, any>) => {
|
||||
expect(resp.status).to.eql(403);
|
||||
expect(resp.body.message).to.eql('Unable to create url');
|
||||
});
|
||||
});
|
||||
|
||||
it(`users with "url_create" access to ${feature.featureId} ${
|
||||
feature.canCreate ? 'should' : 'should not'
|
||||
} be able to create short-urls`, async () => {
|
||||
await supertest
|
||||
.post(`/api/shorten_url`)
|
||||
.auth(`${feature.featureId}-minimal-shorten-user`, kibanaUserPassword)
|
||||
.set('kbn-xsrf', 'foo')
|
||||
.send({ url: '/app/dashboard' })
|
||||
.then((resp: Record<string, any>) => {
|
||||
if (feature.canCreate) {
|
||||
expect(resp.status).to.eql(200);
|
||||
} else {
|
||||
expect(resp.status).to.eql(403);
|
||||
expect(resp.body.message).to.eql('Unable to create url');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -1,14 +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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { FtrProviderContext } from '../../ftr_provider_context';
|
||||
|
||||
export default function shortUrlsApiIntegrationTests({ loadTestFile }: FtrProviderContext) {
|
||||
describe('Short URLs', () => {
|
||||
loadTestFile(require.resolve('./feature_controls'));
|
||||
});
|
||||
}
|
|
@ -22446,6 +22446,11 @@ random-poly-fill@^1.0.1:
|
|||
resolved "https://registry.yarnpkg.com/random-poly-fill/-/random-poly-fill-1.0.1.tgz#13634dc0255a31ecf85d4a182d92c40f9bbcf5ed"
|
||||
integrity sha512-bMOL0hLfrNs52+EHtIPIXxn2PxYwXb0qjnKruTjXiM/sKfYqj506aB2plFwWW1HN+ri724bAVVGparh4AtlJKw==
|
||||
|
||||
random-word-slugs@^0.0.5:
|
||||
version "0.0.5"
|
||||
resolved "https://registry.yarnpkg.com/random-word-slugs/-/random-word-slugs-0.0.5.tgz#6ccd6c7ea320be9fbc19507f8c3a7d4a970ff61f"
|
||||
integrity sha512-KwelmWsWHiMl3MKauB5usAIPg2FgwAku+FVYEuf32yyhZmEh3Fq4nXBxeUAgXB2F+G/HTeDsqXsmuupmOMnjRg==
|
||||
|
||||
randombytes@^2.0.0, randombytes@^2.0.1, randombytes@^2.0.5, randombytes@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue