Skip to content

Commit

Permalink
Parse deep links sent to Connect (#33639)
Browse files Browse the repository at this point in the history
* Reorganize uri & tests

* uri routing: Use `routing` instead of `this`

`this` used within objects like this loses type information due to implicit
any used by TypeScript there. Instead, we can refer to `routing` (like
other functions already do) and keep type information.

* Add parseConnectMyComputerUri

* Parse and validate deep link in main process
  • Loading branch information
ravicious committed Oct 20, 2023
1 parent 4ed8c5e commit 0af059a
Show file tree
Hide file tree
Showing 7 changed files with 473 additions and 60 deletions.
2 changes: 2 additions & 0 deletions web/packages/teleterm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"@grpc/grpc-js": "1.8.8",
"@types/google-protobuf": "^3.10.0",
"@types/node-forge": "^1.0.4",
"@types/whatwg-url": "^11.0.1",
"clean-webpack-plugin": "4.0.0",
"cross-env": "5.0.5",
"electron": "25.9.0",
Expand All @@ -55,6 +56,7 @@
"react-dnd-html5-backend": "^14.0.2",
"split2": "4.1.0",
"ts-loader": "^9.4.2",
"whatwg-url": "^13.0.0",
"winston": "^3.3.3",
"xterm": "^5.0.0",
"xterm-addon-fit": "^0.7.0",
Expand Down
84 changes: 84 additions & 0 deletions web/packages/teleterm/src/deepLinks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Copyright 2023 Gravitational, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { DeepLinkParseResult, parseDeepLink } from './deepLinks';
import { routing } from './ui/uri';

beforeEach(() => {
jest.restoreAllMocks();
});

describe('parseDeepLink', () => {
describe('valid input', () => {
const tests: Array<string> = [
'teleport:///clusters/foo/connect_my_computer',
'teleport:///clusters/test.example.com/connect_my_computer?username=alice@example.com',
];

test.each(tests)('%s', input => {
jest.spyOn(routing, 'parseDeepLinkUri');
const uri = input.replace('teleport://', '');

const result = parseDeepLink(input);

expect(result.status).toBe('success');
expect(result.status === 'success' && result.parsedUri).not.toBeFalsy();
expect(routing.parseDeepLinkUri).toHaveBeenCalledWith(uri);
});
});

describe('invalid input', () => {
const tests: Array<{ input: string; output: DeepLinkParseResult }> = [
{
input: 'teleport://hello\\foo@bar:baz',
output: {
status: 'error',
reason: 'malformed-url',
error: expect.any(TypeError),
},
},
{
input: 'teleport:///clusters/foo',
output: {
status: 'error',
reason: 'unsupported-uri',
},
},
{
input: 'teleport:///foo/bar',
output: {
status: 'error',
reason: 'unsupported-uri',
},
},
{
input: 'foobar:///clusters/foo/connect_my_computer',
output: {
status: 'error',
reason: 'unknown-protocol',
protocol: 'foobar:',
},
},
];

test.each(tests)('$input', ({ input, output }) => {
jest.spyOn(routing, 'parseDeepLinkUri').mockImplementation(() => null);

const result = parseDeepLink(input);
expect(result).toEqual(output);
});
});
});
81 changes: 81 additions & 0 deletions web/packages/teleterm/src/deepLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Copyright 2023 Gravitational, Inc
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import * as whatwg from 'whatwg-url';

import {
TELEPORT_CUSTOM_PROTOCOL,
DeepLinkParsedUri,
routing,
} from 'teleterm/ui/uri';

export type DeepLinkParseResult =
// Just having a field like `ok: true` for success and `status: 'error'` for errors would be much more
// ergonomic. Unfortunately, `if (!result.ok)` doesn't narrow down the type properly with
// strictNullChecks off. https://github.com/microsoft/TypeScript/issues/10564
| { status: 'success'; parsedUri: DeepLinkParsedUri }
| ParseError<'malformed-url', { error: TypeError }>
| ParseError<'unknown-protocol', { protocol: string }>
| ParseError<'unsupported-uri'>;

type ParseError<Reason, AdditionalData = void> = AdditionalData extends void
? {
status: 'error';
reason: Reason;
}
: {
status: 'error';
reason: Reason;
} & AdditionalData;

/**
* parseDeepLink receives a full URL of a deep link passed to Connect, e.g.
* teleport:///clusters/foo/connect_my_computer and returns its parsed form if the underlying URI is
* supported by the app.
*
* Returning a parsed form was a conscious decision – this way it's clear that the parsed form is
* valid and can be passed along safely from the main process to the renderer vs raw string URLs
* which don't carry any information by themselves about their validity – in that scenario, they'd
* have to be parsed on both ends.
*/
export function parseDeepLink(rawUrl: string): DeepLinkParseResult {
let url: whatwg.URL;
try {
url = new whatwg.URL(rawUrl);
} catch (error) {
if (error instanceof TypeError) {
// Invalid URL.
return { status: 'error', reason: 'malformed-url', error };
}
throw error;
}

if (url.protocol !== `${TELEPORT_CUSTOM_PROTOCOL}:`) {
return {
status: 'error',
reason: 'unknown-protocol',
protocol: url.protocol,
};
}

const uri = url.pathname + url.search;
const parsedUri = routing.parseDeepLinkUri(uri);

if (!parsedUri) {
return { status: 'error', reason: 'unsupported-uri' };
}

return { status: 'success', parsedUri };
}
32 changes: 32 additions & 0 deletions web/packages/teleterm/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
import { createFileStorage } from 'teleterm/services/fileStorage';
import { WindowsManager } from 'teleterm/mainProcess/windowsManager';
import { TELEPORT_CUSTOM_PROTOCOL } from 'teleterm/ui/uri';
import { parseDeepLink } from 'teleterm/deepLinks';
import { assertUnreachable } from 'teleterm/ui/utils';

// Set the app as a default protocol client only if it wasn't started through `electron .`.
if (!process.defaultApp) {
Expand Down Expand Up @@ -290,6 +292,7 @@ function setUpDeepLinks(
windowsManager.focusWindow();

logger.info(`Deep link launch from open-url, URL: ${url}`);
launchDeepLink(logger, url);
});
return;
}
Expand All @@ -308,6 +311,7 @@ function setUpDeepLinks(
const url = findCustomProtocolUrlInArgv(argv);
if (url) {
logger.info(`Deep link launch from second-instance, URI: ${url}`);
launchDeepLink(logger, url);
}
});

Expand All @@ -318,10 +322,38 @@ function setUpDeepLinks(
return;
}
logger.info(`Deep link launch from process.argv, URL: ${url}`);
launchDeepLink(logger, url);
}

// We don't know the exact position of the URL is in argv. Chromium might inject its own arguments
// into argv. See https://www.electronjs.org/docs/latest/api/app#event-second-instance.
function findCustomProtocolUrlInArgv(argv: string[]) {
return argv.find(arg => arg.startsWith(`${TELEPORT_CUSTOM_PROTOCOL}://`));
}

function launchDeepLink(logger: Logger, rawUrl: string): void {
const result = parseDeepLink(rawUrl);

if (result.status === 'error') {
let reason: string;
switch (result.reason) {
case 'unknown-protocol': {
reason = `unknown protocol of the deep link ("${result.protocol}")`;
break;
}
case 'unsupported-uri': {
reason = 'unsupported URI received';
break;
}
case 'malformed-url': {
reason = `malformed URL (${result.error.message})`;
break;
}
default: {
assertUnreachable(result);
}
}

logger.error(`Skipping deep link launch, ${reason}`);
}
}
139 changes: 98 additions & 41 deletions web/packages/teleterm/src/ui/uri.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,49 +16,106 @@

import { Params, routing } from './uri';

const getServerUriTests: Array<
{ name: string; input: Params } & (
| { output: string; wantErr?: never }
| { wantErr: any; output?: never }
)
> = [
{
name: 'returns a server URI for a root cluster',
input: { rootClusterId: 'foo', serverId: 'ubuntu' },
output: '/clusters/foo/servers/ubuntu',
},
{
name: 'returns a server URI for a leaf cluster',
input: { rootClusterId: 'foo', leafClusterId: 'bar', serverId: 'ubuntu' },
output: '/clusters/foo/leaves/bar/servers/ubuntu',
},
{
name: 'throws an error if serverId is missing from the root cluster URI',
input: { rootClusterId: 'foo' },
wantErr: new TypeError('Expected "serverId" to be defined'),
},
{
name: 'throws an error if serverId is missing from the leaf cluster URI',
input: { rootClusterId: 'foo', leafClusterId: 'bar' },
wantErr: new TypeError('Expected "serverId" to be defined'),
},
{
// This isn't necessarily a behavior which we should depend on, but we should document it
// nonetheless.
name: 'returns a server URI if extra params are included',
input: { rootClusterId: 'foo', serverId: 'ubuntu', dbId: 'postgres' },
output: '/clusters/foo/servers/ubuntu',
},
];
/* eslint-disable jest/no-conditional-expect */
test.each(getServerUriTests)(
'getServerUri $name',
({ input, output, wantErr }) => {
describe('getServerUri', () => {
const tests: Array<
{ name: string; input: Params } & (
| { output: string; wantErr?: never }
| { wantErr: any; output?: never }
)
> = [
{
name: 'returns a server URI for a root cluster',
input: { rootClusterId: 'foo', serverId: 'ubuntu' },
output: '/clusters/foo/servers/ubuntu',
},
{
name: 'returns a server URI for a leaf cluster',
input: { rootClusterId: 'foo', leafClusterId: 'bar', serverId: 'ubuntu' },
output: '/clusters/foo/leaves/bar/servers/ubuntu',
},
{
name: 'throws an error if serverId is missing from the root cluster URI',
input: { rootClusterId: 'foo' },
wantErr: new TypeError('Expected "serverId" to be defined'),
},
{
name: 'throws an error if serverId is missing from the leaf cluster URI',
input: { rootClusterId: 'foo', leafClusterId: 'bar' },
wantErr: new TypeError('Expected "serverId" to be defined'),
},
{
// This isn't necessarily a behavior which we should depend on, but we should document it
// nonetheless.
name: 'returns a server URI if extra params are included',
input: { rootClusterId: 'foo', serverId: 'ubuntu', dbId: 'postgres' },
output: '/clusters/foo/servers/ubuntu',
},
];

/* eslint-disable jest/no-conditional-expect */
test.each(tests)('$name', ({ input, output, wantErr }) => {
if (wantErr) {
expect(() => routing.getServerUri(input)).toThrow(wantErr);
} else {
expect(routing.getServerUri(input)).toEqual(output);
}
}
);
/* eslint-enable jest/no-conditional-expect */
});
/* eslint-enable jest/no-conditional-expect */
});

describe('parseConnectMyComputerUri', () => {
describe('valid input', () => {
const tests: Array<{
input: string;
output: Pick<
ReturnType<typeof routing.parseConnectMyComputerUri>,
'url' | 'params' | 'searchParams'
>;
}> = [
{
input: '/clusters/foo/connect_my_computer',
output: {
url: '/clusters/foo/connect_my_computer',
params: { rootClusterId: 'foo' },
searchParams: { username: null },
},
},
{
input:
'/clusters/alice.cloud.gravitational.io/connect_my_computer?username=alice',
output: {
url: '/clusters/alice.cloud.gravitational.io/connect_my_computer',
params: { rootClusterId: 'alice.cloud.gravitational.io' },
searchParams: { username: 'alice' },
},
},
{
input:
'/clusters/foo/connect_my_computer?username=alice.bobinson@company.com',
output: {
url: '/clusters/foo/connect_my_computer',
params: { rootClusterId: 'foo' },
searchParams: { username: 'alice.bobinson@company.com' },
},
},
];
test.each(tests)('$input', ({ input, output }) => {
expect(routing.parseConnectMyComputerUri(input)).toMatchObject(output);
});
});

describe('invalid input', () => {
const tests: Array<string> = [
'/clusters/foo/connect_my_computer/',
'/clusters/foo/connect_my_computer/?username=bob',
'/clusters/foo/connect_my_computer/bar',
'/clusters/foo/connect_my_computer/bar?username=bob',
'/clusters/foo/servers/bar',
'abcdef',
];

test.each(tests)('%s', input => {
expect(routing.parseConnectMyComputerUri(input)).toBeNull();
});
});
});

0 comments on commit 0af059a

Please sign in to comment.