From 6f242eca57a366ddad1af77113efae041b771253 Mon Sep 17 00:00:00 2001 From: Evan Hahn <69474926+EvanHahn-Signal@users.noreply.github.com> Date: Sat, 28 Aug 2021 08:27:38 -0500 Subject: [PATCH] Handle signal.me links --- main.js | 38 ++++++++++---------- preload.js | 13 +++++-- ts/models/conversations.ts | 3 +- ts/test-both/util/isValidE164_test.ts | 35 ++++++++++++++++++ ts/test-node/util/sgnlHref_test.ts | 51 ++++++++++++++++++++++++++- ts/util/createIPCEvents.ts | 50 ++++++++++++++++++++------ ts/util/isValidE164.ts | 22 ++++++++++++ ts/util/sgnlHref.ts | 18 ++++++++-- ts/window.d.ts | 1 - 9 files changed, 195 insertions(+), 36 deletions(-) create mode 100644 ts/test-both/util/isValidE164_test.ts create mode 100644 ts/util/isValidE164.ts diff --git a/main.js b/main.js index 362bc98f8..f35a7260d 100644 --- a/main.js +++ b/main.js @@ -1744,25 +1744,25 @@ function handleSgnlHref(incomingHref) { ({ command, args, hash } = parseSignalHttpsLink(incomingHref, logger)); } - if (command === 'addstickers' && mainWindow && mainWindow.webContents) { - console.log('Opening sticker pack from sgnl protocol link'); - const packId = args.get('pack_id'); - const packKeyHex = args.get('pack_key'); - const packKey = packKeyHex - ? Buffer.from(packKeyHex, 'hex').toString('base64') - : ''; - mainWindow.webContents.send('show-sticker-pack', { packId, packKey }); - } else if ( - command === 'signal.group' && - hash && - mainWindow && - mainWindow.webContents - ) { - console.log('Showing group from sgnl protocol link'); - mainWindow.webContents.send('show-group-via-link', { hash }); - } else if (mainWindow && mainWindow.webContents) { - console.log('Showing warning that we cannot process link'); - mainWindow.webContents.send('unknown-sgnl-link'); + if (mainWindow && mainWindow.webContents) { + if (command === 'addstickers') { + console.log('Opening sticker pack from sgnl protocol link'); + const packId = args.get('pack_id'); + const packKeyHex = args.get('pack_key'); + const packKey = packKeyHex + ? Buffer.from(packKeyHex, 'hex').toString('base64') + : ''; + mainWindow.webContents.send('show-sticker-pack', { packId, packKey }); + } else if (command === 'signal.group' && hash) { + console.log('Showing group from sgnl protocol link'); + mainWindow.webContents.send('show-group-via-link', { hash }); + } else if (command === 'signal.me' && hash) { + console.log('Showing conversation from sgnl protocol link'); + mainWindow.webContents.send('show-conversation-via-signal.me', { hash }); + } else { + console.log('Showing warning that we cannot process link'); + mainWindow.webContents.send('unknown-sgnl-link'); + } } else { console.error('Unhandled sgnl link'); } diff --git a/preload.js b/preload.js index f0a60c606..b92cb7e49 100644 --- a/preload.js +++ b/preload.js @@ -12,6 +12,7 @@ try { const electron = require('electron'); const semver = require('semver'); const _ = require('lodash'); + const { strictAssert } = require('./ts/util/assert'); // It is important to call this as early as possible require('./ts/windows/context'); @@ -301,6 +302,16 @@ try { } }); + ipc.on('show-conversation-via-signal.me', (_event, info) => { + const { hash } = info; + strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC'); + + const { showConversationViaSignalDotMe } = window.Events; + if (showConversationViaSignalDotMe) { + showConversationViaSignalDotMe(hash); + } + }); + ipc.on('unknown-sgnl-link', () => { const { unknownSignalLink } = window.Events; if (unknownSignalLink) { @@ -398,8 +409,6 @@ try { }; window.isValidGuid = isValidGuid; - // https://stackoverflow.com/a/23299989 - window.isValidE164 = maybeE164 => /^\+?[1-9]\d{1,14}$/.test(maybeE164); window.React = require('react'); window.ReactDOM = require('react-dom'); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index aa5241d77..3db059f24 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -33,6 +33,7 @@ import { isConversationSMSOnly } from '../util/isConversationSMSOnly'; import { isConversationUnregistered } from '../util/isConversationUnregistered'; import { missingCaseError } from '../util/missingCaseError'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; +import { isValidE164 } from '../util/isValidE164'; import { MIMEType, IMAGE_WEBP } from '../types/MIME'; import { arrayBufferToBase64, @@ -232,7 +233,7 @@ export class ConversationModel extends window.Backbone } initialize(attributes: Partial = {}): void { - if (window.isValidE164(attributes.id)) { + if (isValidE164(attributes.id, false)) { this.set({ id: window.getGuid(), e164: attributes.id }); } diff --git a/ts/test-both/util/isValidE164_test.ts b/ts/test-both/util/isValidE164_test.ts new file mode 100644 index 000000000..b46bfb839 --- /dev/null +++ b/ts/test-both/util/isValidE164_test.ts @@ -0,0 +1,35 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import { isValidE164 } from '../../util/isValidE164'; + +describe('isValidE164', () => { + it('returns false for non-strings', () => { + assert.isFalse(isValidE164(undefined, false)); + assert.isFalse(isValidE164(18885551234, false)); + assert.isFalse(isValidE164(['+18885551234'], false)); + }); + + it('returns false for invalid E164s', () => { + assert.isFalse(isValidE164('', false)); + assert.isFalse(isValidE164('+05551234', false)); + assert.isFalse(isValidE164('+1800ENCRYPT', false)); + assert.isFalse(isValidE164('+1-888-555-1234', false)); + assert.isFalse(isValidE164('+1 (888) 555-1234', false)); + assert.isFalse(isValidE164('+1012345678901234', false)); + assert.isFalse(isValidE164('+18885551234extra', false)); + }); + + it('returns true for E164s that look valid', () => { + assert.isTrue(isValidE164('+18885551234', false)); + assert.isTrue(isValidE164('+123456789012', false)); + assert.isTrue(isValidE164('+12', false)); + }); + + it('can make the leading + optional or required', () => { + assert.isTrue(isValidE164('18885551234', false)); + assert.isFalse(isValidE164('18885551234', true)); + }); +}); diff --git a/ts/test-node/util/sgnlHref_test.ts b/ts/test-node/util/sgnlHref_test.ts index 9b12c8269..472e61946 100644 --- a/ts/test-node/util/sgnlHref_test.ts +++ b/ts/test-node/util/sgnlHref_test.ts @@ -1,4 +1,4 @@ -// Copyright 2020 Signal Messenger, LLC +// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; @@ -11,6 +11,7 @@ import { isSignalHttpsLink, parseSgnlHref, parseCaptchaHref, + parseE164FromSignalDotMeHash, parseSignalHttpsLink, } from '../../util/sgnlHref'; @@ -131,10 +132,16 @@ describe('sgnlHref', () => { ); }); + it('returns false if the URL is not a valid Signal URL', () => { + assert.isFalse(isSignalHttpsLink('https://signal.org', explodingLogger)); + assert.isFalse(isSignalHttpsLink('https://example.com', explodingLogger)); + }); + it('returns true if the protocol is "https:"', () => { assert.isTrue(isSignalHttpsLink('https://signal.group', explodingLogger)); assert.isTrue(isSignalHttpsLink('https://signal.art', explodingLogger)); assert.isTrue(isSignalHttpsLink('HTTPS://signal.art', explodingLogger)); + assert.isTrue(isSignalHttpsLink('https://signal.me', explodingLogger)); }); it('returns false if username or password are set', () => { @@ -288,6 +295,34 @@ describe('sgnlHref', () => { }); }); + describe('parseE164FromSignalDotMeHash', () => { + it('returns undefined for invalid inputs', () => { + [ + '', + ' p/+18885551234', + 'p/+18885551234 ', + 'x/+18885551234', + 'p/+notanumber', + 'p/7c7e87a0-3b74-4efd-9a00-6eb8b1dd5be8', + 'p/+08885551234', + 'p/18885551234', + ].forEach(hash => { + assert.isUndefined(parseE164FromSignalDotMeHash(hash)); + }); + }); + + it('returns the E164 for valid inputs', () => { + assert.strictEqual( + parseE164FromSignalDotMeHash('p/+18885551234'), + '+18885551234' + ); + assert.strictEqual( + parseE164FromSignalDotMeHash('p/+441632960104'), + '+441632960104' + ); + }); + }); + describe('parseSignalHttpsLink', () => { it('returns a null command for invalid URLs', () => { ['', 'https', 'https://example/?foo=bar'].forEach(href => { @@ -329,5 +364,19 @@ describe('sgnlHref', () => { } ); }); + + it('handles signal.me links', () => { + assert.deepEqual( + parseSignalHttpsLink( + 'https://signal.me/#p/+18885551234', + explodingLogger + ), + { + command: 'signal.me', + args: new Map(), + hash: 'p/+18885551234', + } + ); + }); }); }); diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 62695e84e..6b2229551 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -21,6 +21,7 @@ import { ConversationType } from '../state/ducks/conversations'; import { calling } from '../services/calling'; import { getConversationsWithCustomColorSelector } from '../state/selectors/conversations'; import { getCustomColors } from '../state/selectors/items'; +import { trigger } from '../shims/events'; import { themeChanged } from '../shims/themeChanged'; import { renderClearingDataView } from '../shims/renderClearingDataView'; @@ -30,6 +31,7 @@ import { PhoneNumberSharingMode } from './phoneNumberSharingMode'; import { assert } from './assert'; import * as durations from './durations'; import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled'; +import { parseE164FromSignalDotMeHash } from './sgnlHref'; type ThemeType = 'light' | 'dark' | 'system'; type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; @@ -92,6 +94,7 @@ export type IPCEventsCallbacksType = { removeDarkOverlay: () => void; resetAllChatColors: () => void; resetDefaultChatColor: () => void; + showConversationViaSignalDotMe: (hash: string) => void; showKeyboardShortcuts: () => void; showGroupViaLink: (x: string) => Promise; showStickerPack: (packId: string, key: string) => void; @@ -465,19 +468,33 @@ export function createIPCEvents( } window.isShowingModal = false; }, + showConversationViaSignalDotMe(hash: string) { + if (!window.Signal.Util.Registration.everDone()) { + window.log.info( + 'showConversationViaSignalDotMe: Not registered, returning early' + ); + return; + } + + const maybeE164 = parseE164FromSignalDotMeHash(hash); + if (maybeE164) { + trigger('showConversation', maybeE164); + return; + } + + window.log.info('showConversationViaSignalDotMe: invalid E164'); + if (window.isShowingModal) { + window.log.info( + 'showConversationViaSignalDotMe: a modal is already showing. Doing nothing' + ); + } else { + showUnknownSgnlLinkModal(); + } + }, unknownSignalLink: () => { window.log.warn('unknownSignalLink: Showing error dialog'); - const errorView = new window.Whisper.ReactWrapperView({ - className: 'error-modal-wrapper', - Component: window.Signal.Components.ErrorModal, - props: { - description: window.i18n('unknown-sgnl-link'), - onClose: () => { - errorView.remove(); - }, - }, - }); + showUnknownSgnlLinkModal(); }, installStickerPack: async (packId, key) => { @@ -494,3 +511,16 @@ export function createIPCEvents( ...overrideEvents, }; } + +function showUnknownSgnlLinkModal(): void { + const errorView = new window.Whisper.ReactWrapperView({ + className: 'error-modal-wrapper', + Component: window.Signal.Components.ErrorModal, + props: { + description: window.i18n('unknown-sgnl-link'), + onClose: () => { + errorView.remove(); + }, + }, + }); +} diff --git a/ts/util/isValidE164.ts b/ts/util/isValidE164.ts new file mode 100644 index 000000000..7679abf68 --- /dev/null +++ b/ts/util/isValidE164.ts @@ -0,0 +1,22 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/** + * Returns `true` if the input looks like a valid E164, and `false` otherwise. Note that + * this may return false positives, as it is a fairly naïve check. + * + * See and + * . + */ +export function isValidE164( + value: unknown, + mustStartWithPlus: boolean +): value is string { + if (typeof value !== 'string') { + return false; + } + + const regex = mustStartWithPlus ? /^\+[1-9]\d{1,14}$/ : /^\+?[1-9]\d{1,14}$/; + + return regex.test(value); +} diff --git a/ts/util/sgnlHref.ts b/ts/util/sgnlHref.ts index 8e785e2f9..0507f8851 100644 --- a/ts/util/sgnlHref.ts +++ b/ts/util/sgnlHref.ts @@ -3,6 +3,9 @@ import { LoggerType } from '../types/Logging'; import { maybeParseUrl } from './url'; +import { isValidE164 } from './isValidE164'; + +const SIGNAL_DOT_ME_HASH_PREFIX = 'p/'; function parseUrl(value: string | URL, logger: LoggerType): undefined | URL { if (value instanceof URL) { @@ -41,7 +44,9 @@ export function isSignalHttpsLink( !url.password && !url.port && url.protocol === 'https:' && - (url.host === 'signal.group' || url.host === 'signal.art') + (url.host === 'signal.group' || + url.host === 'signal.art' || + url.host === 'signal.me') ); } @@ -119,7 +124,7 @@ export function parseSignalHttpsLink( }; } - if (url.host === 'signal.group') { + if (url.host === 'signal.group' || url.host === 'signal.me') { return { command: url.host, args: new Map(), @@ -129,3 +134,12 @@ export function parseSignalHttpsLink( return { command: null, args: new Map() }; } + +export function parseE164FromSignalDotMeHash(hash: string): undefined | string { + if (!hash.startsWith(SIGNAL_DOT_ME_HASH_PREFIX)) { + return; + } + + const maybeE164 = hash.slice(SIGNAL_DOT_ME_HASH_PREFIX.length); + return isValidE164(maybeE164, true) ? maybeE164 : undefined; +} diff --git a/ts/window.d.ts b/ts/window.d.ts index 73f028ca2..e76b8a91a 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -229,7 +229,6 @@ declare global { isBeforeVersion: (version: string, anotherVersion: string) => boolean; isFullScreen: () => boolean; isValidGuid: typeof isValidGuid; - isValidE164: (maybeE164: unknown) => boolean; libphonenumber: { util: { getRegionCodeForNumber: (number: string) => string;