diff --git a/main.js b/main.js index a5dfe7274f..1d03473fbf 100644 --- a/main.js +++ b/main.js @@ -5,7 +5,6 @@ const url = require('url'); const os = require('os'); const fs = require('fs-extra'); const crypto = require('crypto'); -const qs = require('qs'); const normalizePath = require('normalize-path'); const fg = require('fast-glob'); const PQueue = require('p-queue').default; @@ -95,6 +94,7 @@ const { installWebHandler, } = require('./app/protocol_filter'); const { installPermissionsHandler } = require('./app/permissions'); +const { isSgnlHref, parseSgnlHref } = require('./ts/util/sgnlHref'); let appStartInitialSpellcheckSetting = true; @@ -149,10 +149,10 @@ if (!process.mas) { showWindow(); } - // Are they trying to open a sgnl link? - const incomingUrl = getIncomingUrl(argv); - if (incomingUrl) { - handleSgnlLink(incomingUrl); + // Are they trying to open a sgnl:// href? + const incomingHref = getIncomingHref(argv); + if (incomingHref) { + handleSgnlHref(incomingHref); } // Handled return true; @@ -470,9 +470,9 @@ async function readyForUpdates() { isReadyForUpdates = true; // First, install requested sticker pack - const incomingUrl = getIncomingUrl(process.argv); - if (incomingUrl) { - handleSgnlLink(incomingUrl); + const incomingHref = getIncomingHref(process.argv); + if (incomingHref) { + handleSgnlHref(incomingHref); } // Second, start checking for app updates @@ -1138,9 +1138,9 @@ app.setAsDefaultProtocolClient('sgnl'); app.on('will-finish-launching', () => { // open-url must be set from within will-finish-launching for macOS // https://stackoverflow.com/a/43949291 - app.on('open-url', (event, incomingUrl) => { + app.on('open-url', (event, incomingHref) => { event.preventDefault(); - handleSgnlLink(incomingUrl); + handleSgnlHref(incomingHref); }); }); @@ -1374,16 +1374,16 @@ function installSettingsSetter(name) { }); } -function getIncomingUrl(argv) { - return argv.find(arg => arg.startsWith('sgnl://')); +function getIncomingHref(argv) { + return argv.find(arg => isSgnlHref(arg, logger)); } -function handleSgnlLink(incomingUrl) { - const { host: command, query } = url.parse(incomingUrl); - const args = qs.parse(query); +function handleSgnlHref(incomingHref) { + const { command, args } = parseSgnlHref(incomingHref, logger); if (command === 'addstickers' && mainWindow && mainWindow.webContents) { console.log('Opening sticker pack from sgnl protocol link'); - const { pack_id: packId, pack_key: packKeyHex } = args; + const packId = args.get('pack_id'); + const packKeyHex = args.get('pack_key'); const packKey = Buffer.from(packKeyHex, 'hex').toString('base64'); mainWindow.webContents.send('show-sticker-pack', { packId, packKey }); } else { diff --git a/package.json b/package.json index e5563b31c0..6510b91f6d 100644 --- a/package.json +++ b/package.json @@ -107,7 +107,6 @@ "pify": "3.0.0", "protobufjs": "6.8.6", "proxy-agent": "3.1.1", - "qs": "6.5.1", "react": "16.8.3", "react-blurhash": "0.1.2", "react-contextmenu": "2.11.0", @@ -176,7 +175,6 @@ "@types/node-fetch": "2.5.7", "@types/normalize-path": "3.0.0", "@types/pify": "3.0.2", - "@types/qs": "6.5.1", "@types/react": "16.8.5", "@types/react-dom": "16.8.2", "@types/react-measure": "2.0.5", diff --git a/ts/test/util/sgnlHref_test.ts b/ts/test/util/sgnlHref_test.ts new file mode 100644 index 0000000000..715d3985a4 --- /dev/null +++ b/ts/test/util/sgnlHref_test.ts @@ -0,0 +1,173 @@ +import { assert } from 'chai'; +import Sinon from 'sinon'; +import { LoggerType } from '../../types/Logging'; + +import { isSgnlHref, parseSgnlHref } from '../../util/sgnlHref'; + +function shouldNeverBeCalled() { + assert.fail('This should never be called'); +} + +const explodingLogger: LoggerType = { + fatal: shouldNeverBeCalled, + error: shouldNeverBeCalled, + warn: shouldNeverBeCalled, + info: shouldNeverBeCalled, + debug: shouldNeverBeCalled, + trace: shouldNeverBeCalled, +}; + +describe('sgnlHref', () => { + describe('isSgnlHref', () => { + it('returns false for non-strings', () => { + const logger = { + ...explodingLogger, + warn: Sinon.spy(), + }; + + const castToString = (value: unknown): string => value as string; + + assert.isFalse(isSgnlHref(castToString(undefined), logger)); + assert.isFalse(isSgnlHref(castToString(null), logger)); + assert.isFalse(isSgnlHref(castToString(123), logger)); + + Sinon.assert.calledThrice(logger.warn); + }); + + it('returns false for invalid URLs', () => { + assert.isFalse(isSgnlHref('', explodingLogger)); + assert.isFalse(isSgnlHref('sgnl', explodingLogger)); + assert.isFalse(isSgnlHref('sgnl://::', explodingLogger)); + }); + + it('returns false if the protocol is not "sgnl:"', () => { + assert.isFalse(isSgnlHref('https://example', explodingLogger)); + assert.isFalse( + isSgnlHref( + 'https://signal.art/addstickers/?pack_id=abc', + explodingLogger + ) + ); + assert.isFalse(isSgnlHref('signal://example', explodingLogger)); + }); + + it('returns true if the protocol is "sgnl:"', () => { + assert.isTrue(isSgnlHref('sgnl://', explodingLogger)); + assert.isTrue(isSgnlHref('sgnl://example', explodingLogger)); + assert.isTrue(isSgnlHref('sgnl://example.com', explodingLogger)); + assert.isTrue(isSgnlHref('SGNL://example', explodingLogger)); + assert.isTrue(isSgnlHref('sgnl://example?foo=bar', explodingLogger)); + assert.isTrue(isSgnlHref('sgnl://example/', explodingLogger)); + assert.isTrue(isSgnlHref('sgnl://example#', explodingLogger)); + + assert.isTrue(isSgnlHref('sgnl:foo', explodingLogger)); + + assert.isTrue(isSgnlHref('sgnl://user:pass@example', explodingLogger)); + assert.isTrue(isSgnlHref('sgnl://example.com:1234', explodingLogger)); + assert.isTrue( + isSgnlHref('sgnl://example.com/extra/path/data', explodingLogger) + ); + assert.isTrue( + isSgnlHref('sgnl://example/?foo=bar#hash', explodingLogger) + ); + }); + + it('accepts URL objects', () => { + const invalid = new URL('https://example.com'); + assert.isFalse(isSgnlHref(invalid, explodingLogger)); + const valid = new URL('sgnl://example'); + assert.isTrue(isSgnlHref(valid, explodingLogger)); + }); + }); + + describe('parseSgnlHref', () => { + it('returns a null command for invalid URLs', () => { + ['', 'sgnl', 'https://example/?foo=bar'].forEach(href => { + assert.deepEqual(parseSgnlHref(href, explodingLogger), { + command: null, + args: new Map(), + }); + }); + }); + + it('parses the command for URLs with no arguments', () => { + [ + 'sgnl://foo', + 'sgnl://foo/', + 'sgnl://foo?', + 'SGNL://foo?', + 'sgnl://user:pass@foo', + 'sgnl://foo/path/data#hash-data', + ].forEach(href => { + assert.deepEqual(parseSgnlHref(href, explodingLogger), { + command: 'foo', + args: new Map(), + }); + }); + }); + + it("parses a command's arguments", () => { + assert.deepEqual( + parseSgnlHref( + 'sgnl://Foo?bar=baz&qux=Quux&num=123&empty=&encoded=hello%20world', + explodingLogger + ), + { + command: 'Foo', + args: new Map([ + ['bar', 'baz'], + ['qux', 'Quux'], + ['num', '123'], + ['empty', ''], + ['encoded', 'hello world'], + ]), + } + ); + }); + + it('treats the port as part of the command', () => { + assert.propertyVal( + parseSgnlHref('sgnl://foo:1234', explodingLogger), + 'command', + 'foo:1234' + ); + }); + + it('ignores duplicate query parameters', () => { + assert.deepPropertyVal( + parseSgnlHref('sgnl://x?foo=bar&foo=totally-ignored', explodingLogger), + 'args', + new Map([['foo', 'bar']]) + ); + }); + + it('ignores other parts of the URL', () => { + [ + 'sgnl://foo?bar=baz', + 'sgnl://foo/?bar=baz', + 'sgnl://foo/lots/of/path?bar=baz', + 'sgnl://foo?bar=baz#hash', + 'sgnl://user:pass@foo?bar=baz', + ].forEach(href => { + assert.deepEqual(parseSgnlHref(href, explodingLogger), { + command: 'foo', + args: new Map([['bar', 'baz']]), + }); + }); + }); + + it("doesn't do anything fancy with arrays or objects in the query string", () => { + // The `qs` module does things like this, which we don't want. + assert.deepPropertyVal( + parseSgnlHref('sgnl://x?foo[]=bar&foo[]=baz', explodingLogger), + 'args', + new Map([['foo[]', 'bar']]) + ); + assert.deepPropertyVal( + parseSgnlHref('sgnl://x?foo[bar][baz]=foobarbaz', explodingLogger), + 'args', + new Map([['foo[bar][baz]', 'foobarbaz']]) + ); + }); + }); +}); diff --git a/ts/util/sgnlHref.ts b/ts/util/sgnlHref.ts new file mode 100644 index 0000000000..3d3e9f6332 --- /dev/null +++ b/ts/util/sgnlHref.ts @@ -0,0 +1,42 @@ +import { LoggerType } from '../types/Logging'; + +function parseUrl(value: unknown, logger: LoggerType): null | URL { + if (value instanceof URL) { + return value; + } else if (typeof value === 'string') { + try { + return new URL(value); + } catch (err) { + return null; + } + } + logger.warn('Tried to parse a sgnl:// URL but got an unexpected type'); + return null; +} + +export function isSgnlHref(value: string | URL, logger: LoggerType): boolean { + const url = parseUrl(value, logger); + return url !== null && url.protocol === 'sgnl:'; +} + +type ParsedSgnlHref = + | { command: null; args: Map } + | { command: string; args: Map }; +export function parseSgnlHref( + href: string, + logger: LoggerType +): ParsedSgnlHref { + const url = parseUrl(href, logger); + if (!url || !isSgnlHref(url, logger)) { + return { command: null, args: new Map() }; + } + + const args = new Map(); + url.searchParams.forEach((value, key) => { + if (!args.has(key)) { + args.set(key, value); + } + }); + + return { command: url.host, args }; +} diff --git a/yarn.lock b/yarn.lock index 959c3b9742..c70868b211 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2339,11 +2339,6 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== -"@types/qs@6.5.1": - version "6.5.1" - resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.5.1.tgz#a38f69c62528d56ba7bd1f91335a8004988d72f7" - integrity sha512-mNhVdZHdtKHMMxbqzNK3RzkBcN1cux3AvuCYGTvjEIQT2uheH3eCAyYsbMbh2Bq8nXkeOWs1kyDiF7geWRFQ4Q== - "@types/range-parser@*": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.3.tgz#7ee330ba7caafb98090bece86a5ee44115904c2c" @@ -12310,10 +12305,6 @@ qs@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/qs/-/qs-5.2.0.tgz#a9f31142af468cb72b25b30136ba2456834916be" -qs@6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" - qs@6.7.0: version "6.7.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc"