diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 8d827514c2..a80819561a 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -1,7 +1,7 @@ # Copyright 2020 Signal Messenger, LLC # SPDX-License-Identifier: AGPL-3.0-only -name: CI +name: Danger on: pull_request: diff --git a/.prettierignore b/.prettierignore index 2698841598..29a2951c3b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -18,6 +18,7 @@ ts/protobuf/*.d.ts ts/protobuf/*.js stylesheets/manifest.css ts/util/lint/exceptions.json +storybook-static # Third-party files node_modules/** diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 08d043de38..87312f7a20 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -3451,6 +3451,28 @@ Signal Desktop makes use of the following open source projects. License: (MIT OR CC0-1.0) +## urlpattern-polyfill + + Copyright 2020 Intel Corporation + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + ## uuid License: MIT diff --git a/app/main.ts b/app/main.ts index fd76ecc8f7..bd5141cdd2 100644 --- a/app/main.ts +++ b/app/main.ts @@ -100,15 +100,6 @@ import { createTemplate } from './menu'; import { installFileHandler, installWebHandler } from './protocol_filter'; import OS from '../ts/util/os/osMain'; import { isProduction } from '../ts/util/version'; -import { - isSgnlHref, - isCaptchaHref, - isSignalHttpsLink, - parseSgnlHref, - parseCaptchaHref, - parseSignalHttpsLink, - rewriteSignalHrefsIfNecessary, -} from '../ts/util/sgnlHref'; import { clearTimeoutIfNecessary } from '../ts/util/clearTimeoutIfNecessary'; import { toggleMaximizedBrowserWindow } from '../ts/util/toggleMaximizedBrowserWindow'; import { ChallengeMainHandler } from '../ts/main/challengeMain'; @@ -124,6 +115,8 @@ import { load as loadLocale } from './locale'; import type { LoggerType } from '../ts/types/Logging'; import { HourCyclePreference } from '../ts/types/I18N'; import { DBVersionFromFutureError } from '../ts/sql/migrations'; +import type { ParsedSignalRoute } from '../ts/util/signalRoutes'; +import { parseSignalRoute } from '../ts/util/signalRoutes'; const STICKER_CREATOR_PARTITION = 'sticker-creator'; @@ -270,18 +263,10 @@ if (!process.mas) { return; } - const incomingCaptchaHref = getIncomingCaptchaHref(argv); - if (incomingCaptchaHref) { - const { captcha } = parseCaptchaHref(incomingCaptchaHref, getLogger()); - challengeHandler.handleCaptcha(captcha); - return true; + const route = maybeGetIncomingSignalRoute(argv); + if (route != null) { + handleSignalRoute(route); } - // Are they trying to open a sgnl:// href? - const incomingHref = getIncomingHref(argv); - if (incomingHref) { - handleSgnlHref(incomingHref); - } - // Handled return true; }); } @@ -475,23 +460,21 @@ async function handleUrl(rawTarget: string) { return; } - const target = rewriteSignalHrefsIfNecessary(rawTarget); + const signalRoute = parseSignalRoute(rawTarget); + + // We only want to specially handle urls that aren't requesting the dev server + if (signalRoute != null) { + handleSignalRoute(signalRoute); + return; + } const { protocol, hostname } = parsedUrl; const isDevServer = process.env.SIGNAL_ENABLE_HTTP && hostname === 'localhost'; - // We only want to specially handle urls that aren't requesting the dev server - if ( - isSgnlHref(target, getLogger()) || - isSignalHttpsLink(target, getLogger()) - ) { - handleSgnlHref(target); - return; - } if ((protocol === 'http:' || protocol === 'https:') && !isDevServer) { try { - await shell.openExternal(target); + await shell.openExternal(rawTarget); } catch (error) { getLogger().error(`Failed to open url: ${Errors.toLogFormat(error)}`); } @@ -1127,9 +1110,9 @@ async function readyForUpdates() { isReadyForUpdates = true; // First, install requested sticker pack - const incomingHref = getIncomingHref(process.argv); + const incomingHref = maybeGetIncomingSignalRoute(process.argv); if (incomingHref) { - handleSgnlHref(incomingHref); + handleSignalRoute(incomingHref); } // Second, start checking for app updates @@ -2199,18 +2182,10 @@ app.on('will-finish-launching', () => { // https://stackoverflow.com/a/43949291 app.on('open-url', (event, incomingHref) => { event.preventDefault(); - - if (isCaptchaHref(incomingHref, getLogger())) { - const { captcha } = parseCaptchaHref(incomingHref, getLogger()); - challengeHandler.handleCaptcha(captcha); - - // Show window after handling captcha - showWindow(); - - return; + const route = parseSignalRoute(incomingHref); + if (route != null) { + handleSignalRoute(route); } - - handleSgnlHref(incomingHref); }); }); @@ -2521,78 +2496,71 @@ ipc.on('preferences-changed', () => { } }); -function getIncomingHref(argv: Array) { - return argv.find(arg => isSgnlHref(arg, getLogger())); +function maybeGetIncomingSignalRoute(argv: Array) { + for (const arg of argv) { + const route = parseSignalRoute(arg); + if (route != null) { + return route; + } + } + return null; } -function getIncomingCaptchaHref(argv: Array) { - return argv.find(arg => isCaptchaHref(arg, getLogger())); -} +function handleSignalRoute(route: ParsedSignalRoute) { + const log = getLogger(); -function handleSgnlHref(incomingHref: string) { - let command; - let args; - let hash; - - if (isSgnlHref(incomingHref, getLogger())) { - ({ command, args, hash } = parseSgnlHref(incomingHref, getLogger())); - } else if (isSignalHttpsLink(incomingHref, getLogger())) { - ({ command, args, hash } = parseSignalHttpsLink(incomingHref, getLogger())); + if (mainWindow == null || !mainWindow.webContents) { + log.error('handleSignalRoute: mainWindow is null or missing webContents'); + return; } - if (mainWindow && mainWindow.webContents) { - if (command === 'addstickers') { - getLogger().info('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 === 'art-auth') { - const token = args?.get('token'); - const pubKeyBase64 = args?.get('pub_key'); + log.info('handleSignalRoute: Matched signal route:', route.key); - mainWindow.webContents.send('authorize-art-creator', { - token, - pubKeyBase64, - }); - } else if (command === 'signal.group' && hash) { - getLogger().info('Showing group from sgnl protocol link'); - mainWindow.webContents.send('show-group-via-link', { hash }); - } else if (command === 'signal.me' && hash) { - getLogger().info('Showing conversation from sgnl protocol link'); - mainWindow.webContents.send('show-conversation-via-signal.me', { hash }); - } else if ( - command === 'show-conversation' && - args && - args.get('conversationId') - ) { - getLogger().info('Showing conversation from notification'); - mainWindow.webContents.send('show-conversation-via-notification', { - conversationId: args.get('conversationId'), - messageId: args.get('messageId'), - storyId: args.get('storyId'), - }); - } else if ( - command === 'start-call-lobby' && - args && - args.get('conversationId') - ) { - getLogger().info('Starting call lobby from notification'); - mainWindow.webContents.send('start-call-lobby', { - conversationId: args.get('conversationId'), - }); - } else if (command === 'show-window') { - mainWindow.webContents.send('show-window'); - } else if (command === 'set-is-presenting') { - mainWindow.webContents.send('set-is-presenting'); - } else { - getLogger().info('Showing warning that we cannot process link'); - mainWindow.webContents.send('unknown-sgnl-link'); - } + if (route.key === 'artAddStickers') { + mainWindow.webContents.send('show-sticker-pack', { + packId: route.args.packId, + packKey: Buffer.from(route.args.packKey, 'hex').toString('base64'), + }); + } else if (route.key === 'artAuth') { + mainWindow.webContents.send('authorize-art-creator', { + token: route.args.token, + pubKeyBase64: route.args.pubKey, + }); + } else if (route.key === 'groupInvites') { + mainWindow.webContents.send('show-group-via-link', { + value: route.args.inviteCode, + }); + } else if (route.key === 'contactByPhoneNumber') { + mainWindow.webContents.send('show-conversation-via-signal.me', { + kind: 'phoneNumber', + value: route.args.phoneNumber, + }); + } else if (route.key === 'contactByEncryptedUsername') { + mainWindow.webContents.send('show-conversation-via-signal.me', { + kind: 'encryptedUsername', + value: route.args.encryptedUsername, + }); + } else if (route.key === 'showConversation') { + mainWindow.webContents.send('show-conversation-via-notification', { + conversationId: route.args.conversationId, + messageId: route.args.messageId, + storyId: route.args.storyId, + }); + } else if (route.key === 'startCallLobby') { + mainWindow.webContents.send('start-call-lobby', { + conversationId: route.args.conversationId, + }); + } else if (route.key === 'showWindow') { + mainWindow.webContents.send('show-window'); + } else if (route.key === 'setIsPresenting') { + mainWindow.webContents.send('set-is-presenting'); + } else if (route.key === 'captcha') { + challengeHandler.handleCaptcha(route.args.captchaId); + // Show window after handling captcha + showWindow(); } else { - getLogger().error('Unhandled sgnl link'); + log.info('handleSignalRoute: Unknown signal route:', route.key); + mainWindow.webContents.send('unknown-sgnl-link'); } } diff --git a/app/renderWindowsToast.tsx b/app/renderWindowsToast.tsx index 70faa46abb..b48d0fa515 100644 --- a/app/renderWindowsToast.tsx +++ b/app/renderWindowsToast.tsx @@ -8,6 +8,12 @@ import type { WindowsNotificationData } from '../ts/services/notifications'; import { NotificationType } from '../ts/services/notifications'; import { missingCaseError } from '../ts/util/missingCaseError'; +import { + setIsPresentingRoute, + showConversationRoute, + showWindowRoute, + startCallLobbyRoute, +} from '../ts/util/signalRoutes'; function pathToUri(path: string) { return `file:///${encodeURI(path.replace(/\\/g, '/'))}`; @@ -51,21 +57,19 @@ export function renderWindowsToast({ // 1) this maps to the notify() function in services/notifications.ts // 2) this also maps to the url-handling in main.ts if (type === NotificationType.Message || type === NotificationType.Reaction) { - launch = new URL('sgnl://show-conversation'); - launch.searchParams.set('conversationId', conversationId); - if (messageId) { - launch.searchParams.set('messageId', messageId); - } - if (storyId) { - launch.searchParams.set('storyId', storyId); - } + launch = showConversationRoute.toAppUrl({ + conversationId, + messageId: messageId ?? null, + storyId: storyId ?? null, + }); } else if (type === NotificationType.IncomingGroupCall) { - launch = new URL(`sgnl://start-call-lobby`); - launch.searchParams.set('conversationId', conversationId); + launch = startCallLobbyRoute.toAppUrl({ + conversationId, + }); } else if (type === NotificationType.IncomingCall) { - launch = new URL('sgnl://show-window'); + launch = showWindowRoute.toAppUrl({}); } else if (type === NotificationType.IsPresenting) { - launch = new URL('sgnl://set-is-presenting'); + launch = setIsPresentingRoute.toAppUrl({}); } else { throw missingCaseError(type); } diff --git a/package.json b/package.json index ee3135570f..4fba498401 100644 --- a/package.json +++ b/package.json @@ -181,6 +181,7 @@ "semver": "5.7.2", "split2": "4.0.0", "type-fest": "3.5.0", + "urlpattern-polyfill": "9.0.0", "uuid": "3.3.2", "uuid-browser": "3.1.0", "websocket": "1.0.34", diff --git a/ts/CI.ts b/ts/CI.ts index dfa7506fd9..98cfc5e718 100644 --- a/ts/CI.ts +++ b/ts/CI.ts @@ -9,6 +9,8 @@ import * as log from './logging/log'; import { explodePromise } from './util/explodePromise'; import { ipcInvoke } from './sql/channels'; import { SECOND } from './util/durations'; +import { isSignalRoute } from './util/signalRoutes'; +import { strictAssert } from './util/assert'; type ResolveType = (data: unknown) => void; @@ -28,6 +30,7 @@ export type CIType = { ignorePastEvents?: boolean; } ) => unknown; + openSignalRoute(url: string): Promise; }; export function getCI(deviceName: string): CIType { @@ -133,6 +136,20 @@ export function getCI(deviceName: string): CIType { return window.ConversationController.getConversationId(address); } + async function openSignalRoute(url: string) { + strictAssert( + isSignalRoute(url), + `openSignalRoute: not a valid signal route ${url}` + ); + const a = document.createElement('a'); + a.href = url; + a.target = '_blank'; + a.hidden = true; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + return { deviceName, getConversationId, @@ -141,5 +158,6 @@ export function getCI(deviceName: string): CIType { setProvisioningURL, solveChallenge, waitForEvent, + openSignalRoute, }; } diff --git a/ts/groups.ts b/ts/groups.ts index 3e73bcfc51..16e451bcfc 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -95,6 +95,7 @@ import { ReadStatus } from './messages/MessageReadStatus'; import { SeenStatus } from './MessageSeenStatus'; import { incrementMessageCounter } from './util/incrementMessageCounter'; import { sleep } from './util/sleep'; +import { groupInvitesRoute } from './util/signalRoutes'; type AccessRequiredEnum = Proto.AccessControl.AccessRequired; @@ -383,16 +384,16 @@ export function buildGroupLink( }, }).finish(); - const hash = toWebSafeBase64(Bytes.toBase64(bytes)); + const inviteCode = toWebSafeBase64(Bytes.toBase64(bytes)); - return `https://signal.group/#${hash}`; + return groupInvitesRoute.toWebUrl({ inviteCode }).toString(); } -export function parseGroupLink(hash: string): { +export function parseGroupLink(value: string): { masterKey: string; inviteLinkPassword: string; } { - const base64 = fromWebSafeBase64(hash); + const base64 = fromWebSafeBase64(value); const buffer = Bytes.fromBase64(base64); const inviteLinkProto = Proto.GroupInviteLink.decode(buffer); diff --git a/ts/groups/joinViaLink.ts b/ts/groups/joinViaLink.ts index e6fe08e892..94b5ffec2e 100644 --- a/ts/groups/joinViaLink.ts +++ b/ts/groups/joinViaLink.ts @@ -30,11 +30,11 @@ import { isGroupV1 } from '../util/whatTypeOfConversation'; import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper'; import { sleep } from '../util/sleep'; -export async function joinViaLink(hash: string): Promise { +export async function joinViaLink(value: string): Promise { let inviteLinkPassword: string; let masterKey: string; try { - ({ inviteLinkPassword, masterKey } = parseGroupLink(hash)); + ({ inviteLinkPassword, masterKey } = parseGroupLink(value)); } catch (error: unknown) { const errorString = Errors.toLogFormat(error); log.error(`joinViaLink: Failed to parse group link ${errorString}`); diff --git a/ts/services/notifications.ts b/ts/services/notifications.ts index fc2904e8c6..dbbd940d8b 100644 --- a/ts/services/notifications.ts +++ b/ts/services/notifications.ts @@ -36,8 +36,8 @@ type NotificationDataType = Readonly<{ export type NotificationClickData = Readonly<{ conversationId: string; - messageId?: string; - storyId?: string; + messageId: string | null; + storyId: string | null; }>; export type WindowsNotificationData = { avatarPath?: string; @@ -208,8 +208,8 @@ class NotificationService extends EventEmitter { window.IPC.showWindow(); window.Events.showConversationViaNotification({ conversationId, - messageId, - storyId, + messageId: messageId ?? null, + storyId: storyId ?? null, }); } else if (type === NotificationType.IncomingGroupCall) { window.IPC.showWindow(); diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index e1ed9a2cb9..76849ef91e 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -18,9 +18,9 @@ import { DEFAULT_CONVERSATION_COLOR } from '../../types/Colors'; import { getPreferredReactionEmoji as getPreferredReactionEmojiFromStoredValue } from '../../reactions/preferredReactionEmoji'; import { isBeta } from '../../util/version'; import { DurationInSeconds } from '../../util/durations'; -import { generateUsernameLink } from '../../util/sgnlHref'; import * as Bytes from '../../Bytes'; import { getUserNumber, getUserACI } from './user'; +import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes'; const DEFAULT_PREFERRED_LEFT_PANE_WIDTH = 320; @@ -112,7 +112,9 @@ export const getUsernameLink = createSelector( const content = Bytes.concatenate([entropy, serverId]); - return generateUsernameLink(Bytes.toBase64(content)); + return contactByEncryptedUsernameRoute + .toWebUrl({ encryptedUsername: Bytes.toBase64(content) }) + .toString(); } ); diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts index b130b82362..cf9fb5b26e 100644 --- a/ts/test-mock/playwright.ts +++ b/ts/test-mock/playwright.ts @@ -136,6 +136,13 @@ export class App extends EventEmitter { return this.app.firstWindow(); } + public async openSignalRoute(url: URL | string): Promise { + const window = await this.getWindow(); + await window.evaluate( + `window.SignalCI.openSignalRoute(${JSON.stringify(url.toString())})` + ); + } + // EventEmitter types public override on(type: 'close', callback: () => void): this; diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 0713bfd3eb..b67f3e42bc 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -9,11 +9,11 @@ import createDebug from 'debug'; import * as durations from '../../util/durations'; import { uuidToBytes } from '../../util/uuidToBytes'; -import { generateUsernameLink } from '../../util/sgnlHref'; import { MY_STORY_ID } from '../../types/Stories'; import { Bootstrap } from '../bootstrap'; import type { App } from '../bootstrap'; import { bufferToUuid } from '../helpers'; +import { contactByEncryptedUsernameRoute } from '../../util/signalRoutes'; export const debug = createDebug('mock:test:username'); @@ -310,9 +310,14 @@ describe('pnp/username', function (this: Mocha.Suite) { CARL_USERNAME ); - const linkUrl = generateUsernameLink( - Buffer.concat([entropy, uuidToBytes(serverId)]).toString('base64') - ); + const linkUrl = contactByEncryptedUsernameRoute + .toWebUrl({ + encryptedUsername: Buffer.concat([ + entropy, + uuidToBytes(serverId), + ]).toString('base64'), + }) + .toString(); debug('sending link to Note to Self'); await phone.sendText(desktop, linkUrl, { diff --git a/ts/test-mock/routing/routing_test.ts b/ts/test-mock/routing/routing_test.ts new file mode 100644 index 0000000000..01c3cb57b9 --- /dev/null +++ b/ts/test-mock/routing/routing_test.ts @@ -0,0 +1,79 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; + +import * as durations from '../../util/durations'; +import type { Bootstrap, App } from '../bootstrap'; +import { + artAddStickersRoute, + showConversationRoute, +} from '../../util/signalRoutes'; +import { + initStorage, + STICKER_PACKS, + storeStickerPacks, +} from '../storage/fixtures'; +import { strictAssert } from '../../util/assert'; + +describe('routing', function (this: Mocha.Suite) { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + + beforeEach(async () => { + ({ bootstrap, app } = await initStorage()); + }); + + afterEach(async function (this: Mocha.Context) { + await bootstrap.maybeSaveLogs(this.currentTest, app); + await app.close(); + await bootstrap.teardown(); + }); + + it('artAddStickersRoute', async () => { + const { server } = bootstrap; + const stickerPack = STICKER_PACKS[0]; + await storeStickerPacks(server, [stickerPack]); + const stickerUrl = artAddStickersRoute.toWebUrl({ + packId: stickerPack.id.toString('hex'), + packKey: stickerPack.key.toString('hex'), + }); + await app.openSignalRoute(stickerUrl); + const page = await app.getWindow(); + const title = page.locator( + '.module-sticker-manager__preview-modal__footer--title', + { hasText: 'Test Stickerpack' } + ); + await title.waitFor(); + assert.isTrue(await title.isVisible()); + }); + + it('showConversationRoute', async () => { + const { contacts } = bootstrap; + const [friend] = contacts; + const page = await app.getWindow(); + await page.locator('#LeftPane').waitFor(); + const conversationId = await page.evaluate( + serviceId => window.SignalCI?.getConversationId(serviceId), + friend.toContact().aci + ); + strictAssert( + typeof conversationId === 'string', + 'conversationId must exist' + ); + const conversationUrl = showConversationRoute.toAppUrl({ + conversationId, + messageId: null, + storyId: null, + }); + await app.openSignalRoute(conversationUrl); + const title = page.locator( + '.module-ConversationHeader__header__info__title', + { hasText: 'Alice Smith' } + ); + await title.waitFor(); + assert.isTrue(await title.isVisible()); + }); +}); diff --git a/ts/test-mock/storage/fixtures.ts b/ts/test-mock/storage/fixtures.ts index d7e4c943f5..0dd95adbcb 100644 --- a/ts/test-mock/storage/fixtures.ts +++ b/ts/test-mock/storage/fixtures.ts @@ -2,13 +2,22 @@ // SPDX-License-Identifier: AGPL-3.0-only import createDebug from 'debug'; -import type { Group, PrimaryDevice } from '@signalapp/mock-server'; +import type { + Group, + PrimaryDevice, + Server, + StorageStateRecord, +} from '@signalapp/mock-server'; import { StorageState, Proto } from '@signalapp/mock-server'; +import path from 'path'; +import fs from 'fs/promises'; +import { range } from 'lodash'; import { App } from '../playwright'; import { Bootstrap } from '../bootstrap'; import type { BootstrapOptions } from '../bootstrap'; import { MY_STORY_ID } from '../../types/Stories'; import { uuidToBytes } from '../../util/uuidToBytes'; +import { artAddStickersRoute } from '../../util/signalRoutes'; export const debug = createDebug('mock:test:storage'); @@ -123,3 +132,77 @@ export async function initStorage( throw error; } } + +export const FIXTURES = path.join(__dirname, '..', '..', '..', 'fixtures'); + +export const EMPTY = new Uint8Array(0); + +export type StickerPackType = Readonly<{ + id: Buffer; + key: Buffer; + stickerCount: number; +}>; + +export const STICKER_PACKS: ReadonlyArray = [ + { + id: Buffer.from('c40ed069cdc2b91eccfccf25e6bcddfc', 'hex'), + key: Buffer.from( + 'cefadd6e81c128680aead1711eb5c92c10f63bdfbc78528a4519ba682de396e4', + 'hex' + ), + stickerCount: 1, + }, + { + id: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'), + key: Buffer.from( + '53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca', + 'hex' + ), + stickerCount: 1, + }, +]; + +export function getStickerPackLink(pack: StickerPackType): string { + return artAddStickersRoute + .toWebUrl({ + packId: pack.id.toString('hex'), + packKey: pack.key.toString('hex'), + }) + .toString(); +} + +export function getStickerPackRecordPredicate( + pack: StickerPackType +): (record: StorageStateRecord) => boolean { + return ({ type, record }: StorageStateRecord): boolean => { + if (type !== IdentifierType.STICKER_PACK) { + return false; + } + return pack.id.equals(record.stickerPack?.packId ?? EMPTY); + }; +} + +export async function storeStickerPacks( + server: Server, + stickerPacks: ReadonlyArray +): Promise { + await Promise.all( + stickerPacks.map(async ({ id, stickerCount }) => { + const hexId = id.toString('hex'); + + await server.storeStickerPack({ + id, + manifest: await fs.readFile( + path.join(FIXTURES, `stickerpack-${hexId}.bin`) + ), + stickers: await Promise.all( + range(0, stickerCount).map(async index => + fs.readFile( + path.join(FIXTURES, `stickerpack-${hexId}-${index}.bin`) + ) + ) + ), + }); + }) + ); +} diff --git a/ts/test-mock/storage/sticker_test.ts b/ts/test-mock/storage/sticker_test.ts index e873c78f59..c8c67bfd75 100644 --- a/ts/test-mock/storage/sticker_test.ts +++ b/ts/test-mock/storage/sticker_test.ts @@ -2,66 +2,21 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; -import { range } from 'lodash'; import { Proto } from '@signalapp/mock-server'; -import type { StorageStateRecord } from '@signalapp/mock-server'; -import fs from 'fs/promises'; -import path from 'path'; - import * as durations from '../../util/durations'; import type { App, Bootstrap } from './fixtures'; -import { initStorage, debug } from './fixtures'; +import { + initStorage, + debug, + STICKER_PACKS, + EMPTY, + storeStickerPacks, + getStickerPackRecordPredicate, + getStickerPackLink, +} from './fixtures'; const { StickerPackOperation } = Proto.SyncMessage; -const FIXTURES = path.join(__dirname, '..', '..', '..', 'fixtures'); -const IdentifierType = Proto.ManifestRecord.Identifier.Type; - -const EMPTY = new Uint8Array(0); - -export type StickerPackType = Readonly<{ - id: Buffer; - key: Buffer; - stickerCount: number; -}>; - -const STICKER_PACKS: ReadonlyArray = [ - { - id: Buffer.from('c40ed069cdc2b91eccfccf25e6bcddfc', 'hex'), - key: Buffer.from( - 'cefadd6e81c128680aead1711eb5c92c10f63bdfbc78528a4519ba682de396e4', - 'hex' - ), - stickerCount: 1, - }, - { - id: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'), - key: Buffer.from( - '53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca', - 'hex' - ), - stickerCount: 1, - }, -]; - -function getStickerPackLink(pack: StickerPackType): string { - return ( - `https://signal.art/addstickers/#pack_id=${pack.id.toString('hex')}&` + - `pack_key=${pack.key.toString('hex')}` - ); -} - -function getStickerPackRecordPredicate( - pack: StickerPackType -): (record: StorageStateRecord) => boolean { - return ({ type, record }: StorageStateRecord): boolean => { - if (type !== IdentifierType.STICKER_PACK) { - return false; - } - return pack.id.equals(record.stickerPack?.packId ?? EMPTY); - }; -} - describe('storage service', function (this: Mocha.Suite) { this.timeout(durations.MINUTE); @@ -70,28 +25,8 @@ describe('storage service', function (this: Mocha.Suite) { beforeEach(async () => { ({ bootstrap, app } = await initStorage()); - const { server } = bootstrap; - - await Promise.all( - STICKER_PACKS.map(async ({ id, stickerCount }) => { - const hexId = id.toString('hex'); - - await server.storeStickerPack({ - id, - manifest: await fs.readFile( - path.join(FIXTURES, `stickerpack-${hexId}.bin`) - ), - stickers: await Promise.all( - range(0, stickerCount).map(async index => - fs.readFile( - path.join(FIXTURES, `stickerpack-${hexId}-${index}.bin`) - ) - ) - ), - }); - }) - ); + await storeStickerPacks(server, STICKER_PACKS); }); afterEach(async function (this: Mocha.Context) { diff --git a/ts/test-node/util/getProvisioningUrl_test.ts b/ts/test-node/util/getProvisioningUrl_test.ts deleted file mode 100644 index 478d741bd6..0000000000 --- a/ts/test-node/util/getProvisioningUrl_test.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; -import { size } from '../../util/iterables'; - -import { getProvisioningUrl } from '../../util/getProvisioningUrl'; - -// It'd be nice to run these tests in the renderer, too, but [Chromium's `URL` doesn't -// handle `sgnl:` links correctly][0]. -// -// [0]: https://bugs.chromium.org/p/chromium/issues/detail?id=869291 -describe('getProvisioningUrl', () => { - it('returns a URL with a UUID and public key', () => { - const uuid = 'a08bf1fd-1799-427f-a551-70af747e3956'; - const publicKey = new Uint8Array([9, 8, 7, 6, 5, 4, 3]); - - const result = getProvisioningUrl(uuid, publicKey); - const resultUrl = new URL(result); - - assert.strictEqual(resultUrl.protocol, 'sgnl:'); - assert.strictEqual(resultUrl.host, 'linkdevice'); - assert.strictEqual(size(resultUrl.searchParams.entries()), 2); - assert.strictEqual(resultUrl.searchParams.get('uuid'), uuid); - assert.strictEqual(resultUrl.searchParams.get('pub_key'), 'CQgHBgUEAw=='); - }); -}); diff --git a/ts/test-node/util/sgnlHref_test.ts b/ts/test-node/util/sgnlHref_test.ts deleted file mode 100644 index 18cf997d41..0000000000 --- a/ts/test-node/util/sgnlHref_test.ts +++ /dev/null @@ -1,530 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import { assert } from 'chai'; -import Sinon from 'sinon'; -import type { LoggerType } from '../../types/Logging'; - -import { - isSgnlHref, - isCaptchaHref, - isSignalHttpsLink, - parseSgnlHref, - parseCaptchaHref, - parseE164FromSignalDotMeHash, - parseUsernameBase64FromSignalDotMeHash, - parseSignalHttpsLink, - generateUsernameLink, - rewriteSignalHrefsIfNecessary, -} 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', () => { - [ - { protocol: 'sgnl', check: isSgnlHref, name: 'isSgnlHref' }, - { protocol: 'signalcaptcha', check: isCaptchaHref, name: 'isCaptchaHref' }, - ].forEach(({ protocol, check, name }) => { - describe(name, () => { - it('returns false for non-strings', () => { - const logger = { - ...explodingLogger, - warn: Sinon.spy(), - }; - - const castToString = (value: unknown): string => value as string; - - assert.isFalse(check(castToString(undefined), logger)); - assert.isFalse(check(castToString(null), logger)); - assert.isFalse(check(castToString(123), logger)); - - Sinon.assert.calledThrice(logger.warn); - }); - - it('returns false for invalid URLs', () => { - assert.isFalse(check('', explodingLogger)); - assert.isFalse(check(protocol, explodingLogger)); - assert.isFalse(check(`${protocol}://::`, explodingLogger)); - }); - - it(`returns false if the protocol is not "${protocol}:"`, () => { - assert.isFalse(check('https://example', explodingLogger)); - assert.isFalse( - check('https://signal.art/addstickers/?pack_id=abc', explodingLogger) - ); - assert.isFalse(check('signal://example', explodingLogger)); - }); - - it(`returns true if the protocol is "${protocol}:"`, () => { - assert.isTrue(check(`${protocol}://`, explodingLogger)); - assert.isTrue(check(`${protocol}://example`, explodingLogger)); - assert.isTrue(check(`${protocol}://example.com`, explodingLogger)); - assert.isTrue( - check(`${protocol.toUpperCase()}://example`, explodingLogger) - ); - assert.isTrue(check(`${protocol}://example?foo=bar`, explodingLogger)); - assert.isTrue(check(`${protocol}://example/`, explodingLogger)); - assert.isTrue(check(`${protocol}://example#`, explodingLogger)); - - assert.isTrue(check(`${protocol}:foo`, explodingLogger)); - - assert.isTrue( - check(`${protocol}://user:pass@example`, explodingLogger) - ); - assert.isTrue(check(`${protocol}://example.com:1234`, explodingLogger)); - assert.isTrue( - check(`${protocol}://example.com/extra/path/data`, explodingLogger) - ); - assert.isTrue( - check(`${protocol}://example/?foo=bar#hash`, explodingLogger) - ); - }); - - it('accepts URL objects', () => { - const invalid = new URL('https://example.com'); - assert.isFalse(check(invalid, explodingLogger)); - const valid = new URL(`${protocol}://example`); - assert.isTrue(check(valid, explodingLogger)); - }); - }); - }); - - describe('isSignalHttpsLink', () => { - it('returns false for non-strings', () => { - const logger = { - ...explodingLogger, - warn: Sinon.spy(), - }; - - const castToString = (value: unknown): string => value as string; - - assert.isFalse(isSignalHttpsLink(castToString(undefined), logger)); - assert.isFalse(isSignalHttpsLink(castToString(null), logger)); - assert.isFalse(isSignalHttpsLink(castToString(123), logger)); - - Sinon.assert.calledThrice(logger.warn); - }); - - it('returns false for invalid URLs', () => { - assert.isFalse(isSignalHttpsLink('', explodingLogger)); - assert.isFalse(isSignalHttpsLink('https', explodingLogger)); - assert.isFalse(isSignalHttpsLink('https://::', explodingLogger)); - }); - - it('returns false if the protocol is not "https:"', () => { - assert.isFalse( - isSignalHttpsLink( - 'sgnl://signal.art/#pack_id=234234&pack_key=342342', - explodingLogger - ) - ); - assert.isFalse( - isSignalHttpsLink( - 'sgnl://signal.art/addstickers/#pack_id=234234&pack_key=342342', - explodingLogger - ) - ); - assert.isFalse( - isSignalHttpsLink( - 'signal://signal.group/#AD234Dq342dSDJWE', - explodingLogger - ) - ); - }); - - it('returns false if missing path/hash/query', () => { - assert.isFalse( - isSignalHttpsLink('https://signal.group/', explodingLogger) - ); - assert.isFalse(isSignalHttpsLink('https://signal.art/', explodingLogger)); - assert.isFalse(isSignalHttpsLink('https://signal.me/', explodingLogger)); - }); - - 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/#AD234Dq342dSDJWE', - explodingLogger - ) - ); - assert.isTrue( - isSignalHttpsLink( - 'https://signal.group/AD234Dq342dSDJWE', - explodingLogger - ) - ); - assert.isTrue( - isSignalHttpsLink( - 'https://signal.group/?AD234Dq342dSDJWE', - explodingLogger - ) - ); - assert.isTrue( - isSignalHttpsLink( - 'https://signal.art/addstickers/#pack_id=234234&pack_key=342342', - explodingLogger - ) - ); - assert.isTrue( - isSignalHttpsLink( - 'HTTPS://signal.art/addstickers/#pack_id=234234&pack_key=342342', - explodingLogger - ) - ); - assert.isTrue( - isSignalHttpsLink('https://signal.me/#p/+32423432', explodingLogger) - ); - }); - - it('returns false if username or password are set', () => { - assert.isFalse( - isSignalHttpsLink('https://user:password@signal.group', explodingLogger) - ); - }); - - it('returns false if port is set', () => { - assert.isFalse( - isSignalHttpsLink( - 'https://signal.group:1234/#AD234Dq342dSDJWE', - explodingLogger - ) - ); - }); - - it('accepts URL objects', () => { - const invalid = new URL('sgnl://example.com'); - assert.isFalse(isSignalHttpsLink(invalid, explodingLogger)); - const valid = new URL('https://signal.art/#AD234Dq342dSDJWE'); - assert.isTrue(isSignalHttpsLink(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(), - hash: undefined, - }); - }); - }); - - 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', - ].forEach(href => { - assert.deepEqual(parseSgnlHref(href, explodingLogger), { - command: 'foo', - args: new Map(), - hash: undefined, - }); - }); - }); - - 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'], - ]), - hash: undefined, - } - ); - }); - - 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('includes hash', () => { - [ - 'sgnl://foo?bar=baz#somehash', - 'sgnl://user:pass@foo?bar=baz#somehash', - ].forEach(href => { - assert.deepEqual(parseSgnlHref(href, explodingLogger), { - command: 'foo', - args: new Map([['bar', 'baz']]), - hash: 'somehash', - }); - }); - }); - - it('ignores other parts of the URL', () => { - [ - 'sgnl://foo?bar=baz', - 'sgnl://foo/?bar=baz', - 'sgnl://foo/lots/of/path?bar=baz', - 'sgnl://user:pass@foo?bar=baz', - ].forEach(href => { - assert.deepEqual(parseSgnlHref(href, explodingLogger), { - command: 'foo', - args: new Map([['bar', 'baz']]), - hash: undefined, - }); - }); - }); - - 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']]) - ); - }); - }); - - describe('parseCaptchaHref', () => { - it('throws on invalid URLs', () => { - ['', 'sgnl', 'https://example/?foo=bar'].forEach(href => { - assert.throws( - () => parseCaptchaHref(href, explodingLogger), - 'Not a captcha href' - ); - }); - }); - - it('parses the command for URLs with no arguments', () => { - [ - 'signalcaptcha://foo', - 'signalcaptcha://foo?x=y', - 'signalcaptcha://a:b@foo?x=y', - 'signalcaptcha://foo#hash', - 'signalcaptcha://foo/', - ].forEach(href => { - assert.deepEqual(parseCaptchaHref(href, explodingLogger), { - captcha: 'foo', - }); - }); - }); - }); - - 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('parseUsernameBase64FromSignalDotMeHash', () => { - it('returns undefined for invalid inputs', () => { - ['', ' eu/+18885551234', 'z/18885551234'].forEach(hash => { - assert.isUndefined(parseUsernameBase64FromSignalDotMeHash(hash)); - }); - }); - - it('returns the username for valid inputs', () => { - assert.strictEqual( - parseUsernameBase64FromSignalDotMeHash( - 'eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe' - ), - 'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe' - ); - }); - }); - - describe('generateUsernameLink', () => { - it('generates regular link', () => { - assert.strictEqual( - generateUsernameLink( - 'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe' - ), - 'https://signal.me/#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe' - ); - }); - - it('generates short link', () => { - assert.strictEqual( - generateUsernameLink( - 'E7wk7FTMz/UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4/Efe', - { short: true } - ), - 'signal.me/#eu/E7wk7FTMz_UYjLAsswHpDsGku8CW7yTmlBh8gtd4yqjQlqcbh09F25x0aQT4_Efe' - ); - }); - }); - - describe('parseSignalHttpsLink', () => { - it('returns a null command for invalid URLs', () => { - ['', 'https', 'https://example/?foo=bar'].forEach(href => { - assert.deepEqual(parseSignalHttpsLink(href, explodingLogger), { - command: null, - args: new Map(), - hash: undefined, - }); - }); - }); - - it('handles signal.art links', () => { - assert.deepEqual( - parseSignalHttpsLink( - 'https://signal.art/addstickers/#pack_id=baz&pack_key=Quux&num=123&empty=&encoded=hello%20world', - explodingLogger - ), - { - command: 'addstickers', - args: new Map([ - ['pack_id', 'baz'], - ['pack_key', 'Quux'], - ['num', '123'], - ['empty', ''], - ['encoded', 'hello world'], - ]), - hash: 'pack_id=baz&pack_key=Quux&num=123&empty=&encoded=hello%20world', - } - ); - }); - - it('handles signal.group links', () => { - assert.deepEqual( - parseSignalHttpsLink('https://signal.group/#data', explodingLogger), - { - command: 'signal.group', - args: new Map(), - hash: 'data', - } - ); - }); - - it('handles signal.me links', () => { - assert.deepEqual( - parseSignalHttpsLink( - 'https://signal.me/#p/+18885551234', - explodingLogger - ), - { - command: 'signal.me', - args: new Map(), - hash: 'p/+18885551234', - } - ); - }); - }); - - describe('rewriteSignalHrefsIfNecessary', () => { - it('rewrites http://signal.group hrefs, making them use HTTPS', () => { - assert.strictEqual( - rewriteSignalHrefsIfNecessary('http://signal.group/#abc123'), - 'https://signal.group/#abc123' - ); - }); - - it('rewrites http://signal.art hrefs, making them use HTTPS', () => { - assert.strictEqual( - rewriteSignalHrefsIfNecessary( - 'http://signal.art/addstickers/#pack_id=abc123' - ), - 'https://signal.art/addstickers/#pack_id=abc123' - ); - }); - - it('rewrites http://signal.me hrefs, making them use HTTPS', () => { - assert.strictEqual( - rewriteSignalHrefsIfNecessary('http://signal.me/#p/+18885551234'), - 'https://signal.me/#p/+18885551234' - ); - }); - - it('removes auth if present', () => { - assert.strictEqual( - rewriteSignalHrefsIfNecessary( - 'http://user:pass@signal.group/ab?c=d#ef' - ), - 'https://signal.group/ab?c=d#ef' - ); - assert.strictEqual( - rewriteSignalHrefsIfNecessary( - 'https://user:pass@signal.group/ab?c=d#ef' - ), - 'https://signal.group/ab?c=d#ef' - ); - }); - - it('does nothing to other hrefs', () => { - [ - // Normal URLs - 'http://example.com', - // Already HTTPS - 'https://signal.art/addstickers/#pack_id=abc123', - // Different port - 'http://signal.group:1234/abc?d=e#fg', - // Different subdomain - 'http://subdomain.signal.group/#abcdef', - // Different protocol - 'ftp://signal.group/#abc123', - 'ftp://user:pass@signal.group/#abc123', - ].forEach(href => { - assert.strictEqual(rewriteSignalHrefsIfNecessary(href), href); - }); - }); - }); -}); diff --git a/ts/test-node/util/signalRoutes_test.ts b/ts/test-node/util/signalRoutes_test.ts new file mode 100644 index 0000000000..84e2f3ede2 --- /dev/null +++ b/ts/test-node/util/signalRoutes_test.ts @@ -0,0 +1,205 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { assert } from 'chai'; +import type { ParsedSignalRoute } from '../../util/signalRoutes'; +import { + isSignalRoute, + parseSignalRoute, + toSignalRouteAppUrl, + toSignalRouteUrl, + toSignalRouteWebUrl, +} from '../../util/signalRoutes'; + +describe('signalRoutes', () => { + type CheckConfig = { + hasAppUrl: boolean; + hasWebUrl: boolean; + isRoute: boolean; + }; + + function createCheck(options: Partial = {}) { + const config: CheckConfig = { + hasAppUrl: true, + hasWebUrl: true, + isRoute: true, + ...options, + }; + // Different than `isRoute` because of normalization + const hasRouteUrl = config.hasAppUrl || config.hasWebUrl; + return function check(input: string, expected: ParsedSignalRoute | null) { + const url = new URL(input); + assert.deepEqual(parseSignalRoute(url), expected); + assert.deepEqual(isSignalRoute(url), config.isRoute); + assert.deepEqual(toSignalRouteUrl(url) != null, hasRouteUrl); + assert.deepEqual(toSignalRouteAppUrl(url) != null, config.hasAppUrl); + assert.deepEqual(toSignalRouteWebUrl(url) != null, config.hasWebUrl); + }; + } + + it('nonsense', () => { + const check = createCheck({ + isRoute: false, + hasAppUrl: false, + hasWebUrl: false, + }); + // Charles Entertainment Cheese, what are you doing here? + check('https://www.chuckecheese.com/#p/+1234567890', null); + // Non-route signal urls + check('https://signal.me', null); + check('sgnl://signal.me/#p', null); + check('sgnl://signal.me/#p/', null); + check('sgnl://signal.me/p/+1234567890', null); + check('https://signal.me/?p/+1234567890', null); + }); + + it('normalize', () => { + const check = createCheck({ isRoute: false, hasAppUrl: true }); + check('http://username:password@signal.me:8888/#p/+1234567890', null); + }); + + it('contactByPhoneNumber', () => { + const result: ParsedSignalRoute = { + key: 'contactByPhoneNumber', + args: { phoneNumber: '+1234567890' }, + }; + const check = createCheck(); + check('https://signal.me/#p/+1234567890', result); + check('https://signal.me#p/+1234567890', result); + check('sgnl://signal.me/#p/+1234567890', result); + check('sgnl://signal.me#p/+1234567890', result); + }); + + it('contactByEncryptedUsername', () => { + const result: ParsedSignalRoute = { + key: 'contactByEncryptedUsername', + args: { encryptedUsername: 'foobar' }, + }; + const check = createCheck(); + check('https://signal.me/#eu/foobar', result); + check('https://signal.me#eu/foobar', result); + check('sgnl://signal.me/#eu/foobar', result); + check('sgnl://signal.me#eu/foobar', result); + }); + + it('groupInvites', () => { + const result: ParsedSignalRoute = { + key: 'groupInvites', + args: { inviteCode: 'foobar' }, + }; + const check = createCheck(); + check('https://signal.group/#foobar', result); + check('https://signal.group#foobar', result); + check('sgnl://signal.group/#foobar', result); + check('sgnl://signal.group#foobar', result); + check('sgnl://joingroup/#foobar', result); + check('sgnl://joingroup#foobar', result); + }); + + it('linkDevice', () => { + const result: ParsedSignalRoute = { + key: 'linkDevice', + args: { uuid: 'foo', pubKey: 'bar' }, + }; + const check = createCheck({ hasWebUrl: false }); + check('sgnl://linkdevice/?uuid=foo&pub_key=bar', result); + check('sgnl://linkdevice?uuid=foo&pub_key=bar', result); + }); + + it('captcha', () => { + const result: ParsedSignalRoute = { + key: 'captcha', + args: { captchaId: 'foobar' }, + }; + const check = createCheck({ hasWebUrl: false }); + check('signalcaptcha://foobar', result); + }); + + it('linkCall', () => { + const result: ParsedSignalRoute = { + key: 'linkCall', + args: { key: 'foobar' }, + }; + const check = createCheck(); + check('https://signal.link/call/#key=foobar', result); + check('https://signal.link/call#key=foobar', result); + check('sgnl://signal.link/call/#key=foobar', result); + check('sgnl://signal.link/call#key=foobar', result); + }); + + it('artAuth', () => { + const result: ParsedSignalRoute = { + key: 'artAuth', + args: { token: 'foo', pubKey: 'bar' }, + }; + const check = createCheck({ hasWebUrl: false }); + check('sgnl://art-auth/?token=foo&pub_key=bar', result); + check('sgnl://art-auth?token=foo&pub_key=bar', result); + }); + + it('artAddStickers', () => { + const result: ParsedSignalRoute = { + key: 'artAddStickers', + args: { packId: 'foo', packKey: 'bar' }, + }; + const check = createCheck(); + check('https://signal.art/addstickers/#pack_id=foo&pack_key=bar', result); + check('https://signal.art/addstickers#pack_id=foo&pack_key=bar', result); + check('sgnl://addstickers/?pack_id=foo&pack_key=bar', result); + check('sgnl://addstickers?pack_id=foo&pack_key=bar', result); + }); + + it('showConversation', () => { + const check = createCheck({ isRoute: true, hasWebUrl: false }); + const args1 = 'conversationId=abc'; + const args2 = 'conversationId=abc&messageId=def'; + const args3 = 'conversationId=abc&messageId=def&storyId=ghi'; + const result1: ParsedSignalRoute = { + key: 'showConversation', + args: { conversationId: 'abc', messageId: null, storyId: null }, + }; + const result2: ParsedSignalRoute = { + key: 'showConversation', + args: { conversationId: 'abc', messageId: 'def', storyId: null }, + }; + const result3: ParsedSignalRoute = { + key: 'showConversation', + args: { conversationId: 'abc', messageId: 'def', storyId: 'ghi' }, + }; + check(`sgnl://show-conversation/?${args1}`, result1); + check(`sgnl://show-conversation?${args1}`, result1); + check(`sgnl://show-conversation/?${args2}`, result2); + check(`sgnl://show-conversation?${args2}`, result2); + check(`sgnl://show-conversation/?${args3}`, result3); + check(`sgnl://show-conversation?${args3}`, result3); + }); + + it('startCallLobby', () => { + const result: ParsedSignalRoute = { + key: 'startCallLobby', + args: { conversationId: 'abc' }, + }; + const check = createCheck({ isRoute: true, hasWebUrl: false }); + check('sgnl://start-call-lobby/?conversationId=abc', result); + check('sgnl://start-call-lobby?conversationId=abc', result); + }); + + it('showWindow', () => { + const result: ParsedSignalRoute = { + key: 'showWindow', + args: {}, + }; + const check = createCheck({ isRoute: true, hasWebUrl: false }); + check('sgnl://show-window/', result); + check('sgnl://show-window', result); + }); + + it('setIsPresenting', () => { + const result: ParsedSignalRoute = { + key: 'setIsPresenting', + args: {}, + }; + const check = createCheck({ isRoute: true, hasWebUrl: false }); + check('sgnl://set-is-presenting/', result); + check('sgnl://set-is-presenting', result); + }); +}); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index 3ba20875c4..8b0829b447 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -52,12 +52,12 @@ import { isMoreRecentThan, isOlderThan } from '../util/timestamp'; import { ourProfileKeyService } from '../services/ourProfileKey'; import { assertDev, strictAssert } from '../util/assert'; import { getRegionCodeForNumber } from '../util/libphonenumberUtil'; -import { getProvisioningUrl } from '../util/getProvisioningUrl'; import { isNotNil } from '../util/isNotNil'; import { missingCaseError } from '../util/missingCaseError'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import type { StorageAccessType } from '../types/Storage'; +import { linkDeviceRoute } from '../util/signalRoutes'; type StorageKeyByServiceIdKind = { [kind in ServiceIdKind]: keyof StorageAccessType; @@ -358,7 +358,12 @@ export default class AccountManager extends EventTarget { if (!uuid) { throw new Error('registerSecondDevice: expected a UUID'); } - const url = getProvisioningUrl(uuid, pubKey); + const url = linkDeviceRoute + .toAppUrl({ + uuid, + pubKey: Bytes.toBase64(pubKey), + }) + .toString(); window.SignalCI?.setProvisioningURL(url); diff --git a/ts/types/LinkPreview.ts b/ts/types/LinkPreview.ts index b75c324a17..8e8a25c49e 100644 --- a/ts/types/LinkPreview.ts +++ b/ts/types/LinkPreview.ts @@ -9,6 +9,7 @@ import { maybeParseUrl } from '../util/url'; import { replaceEmojiWithSpaces } from '../util/emoji'; import type { AttachmentWithHydratedData } from './Attachment'; +import { artAddStickersRoute, groupInvitesRoute } from '../util/signalRoutes'; export type LinkPreviewImage = AttachmentWithHydratedData; @@ -95,11 +96,13 @@ export function shouldLinkifyMessage( } export function isStickerPack(link = ''): boolean { - return link.startsWith('https://signal.art/addstickers/'); + const url = maybeParseUrl(link); + return url?.protocol === 'https:' && artAddStickersRoute.isMatch(url); } export function isGroupLink(link = ''): boolean { - return link.startsWith('https://signal.group/'); + const url = maybeParseUrl(link); + return url?.protocol === 'https:' && groupInvitesRoute.isMatch(url); } export function findLinks(text: string, caretLocation?: number): Array { diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index 695573ca00..f68c1556c1 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -35,16 +35,14 @@ import * as durations from './durations'; import type { DurationInSeconds } from './durations'; import { isPhoneNumberSharingEnabled } from './isPhoneNumberSharingEnabled'; import * as Registration from './registration'; -import { - parseE164FromSignalDotMeHash, - parseUsernameBase64FromSignalDotMeHash, -} from './sgnlHref'; import { lookupConversationWithoutServiceId } from './lookupConversationWithoutServiceId'; import * as log from '../logging/log'; import { deleteAllMyStories } from './deleteAllMyStories'; import { isEnabled } from '../RemoteConfig'; import type { NotificationClickData } from '../services/notifications'; import { StoryViewModeType, StoryViewTargetType } from '../types/Stories'; +import { isValidE164 } from './isValidE164'; +import { fromWebSafeBase64 } from './webSafeBase64'; type SentMediaQualityType = 'standard' | 'high'; type ThemeType = 'light' | 'dark' | 'system'; @@ -121,9 +119,12 @@ export type IPCEventsCallbacksType = { resetAllChatColors: () => void; resetDefaultChatColor: () => void; showConversationViaNotification: (data: NotificationClickData) => void; - showConversationViaSignalDotMe: (hash: string) => Promise; + showConversationViaSignalDotMe: ( + kind: string, + value: string + ) => Promise; showKeyboardShortcuts: () => void; - showGroupViaLink: (x: string) => Promise; + showGroupViaLink: (value: string) => Promise; showReleaseNotes: () => void; showStickerPack: (packId: string, key: string) => void; shutdown: () => Promise; @@ -497,14 +498,14 @@ export function createIPCEvents( } window.reduxActions.globalModals.showStickerPackPreview(packId, key); }, - showGroupViaLink: async hash => { + showGroupViaLink: async value => { // We can get these events even if the user has never linked this instance. if (!Registration.everDone()) { log.warn('showGroupViaLink: Not registered, returning early'); return; } try { - await window.Signal.Groups.joinViaLink(hash); + await window.Signal.Groups.joinViaLink(value); } catch (error) { log.error( 'showGroupViaLink: Ran into an error!', @@ -532,14 +533,14 @@ export function createIPCEvents( } else { window.reduxActions.conversations.showConversation({ conversationId, - messageId, + messageId: messageId ?? undefined, }); } } else { window.reduxActions.app.openInbox(); } }, - async showConversationViaSignalDotMe(hash: string) { + async showConversationViaSignalDotMe(kind: string, value: string) { if (!Registration.everDone()) { log.info( 'showConversationViaSignalDotMe: Not registered, returning early' @@ -549,45 +550,35 @@ export function createIPCEvents( const { showUserNotFoundModal } = window.reduxActions.globalModals; - const maybeE164 = parseE164FromSignalDotMeHash(hash); - if (maybeE164) { - const convoId = await lookupConversationWithoutServiceId({ - type: 'e164', - e164: maybeE164, - phoneNumber: maybeE164, - showUserNotFoundModal, - setIsFetchingUUID: noop, - }); - if (convoId) { - window.reduxActions.conversations.showConversation({ - conversationId: convoId, + let conversationId: string | undefined; + + if (kind === 'phoneNumber') { + if (isValidE164(value, true)) { + conversationId = await lookupConversationWithoutServiceId({ + type: 'e164', + e164: value, + phoneNumber: value, + showUserNotFoundModal, + setIsFetchingUUID: noop, + }); + } + } else if (kind === 'encryptedUsername') { + const usernameBase64 = fromWebSafeBase64(value); + const username = await resolveUsernameByLinkBase64(usernameBase64); + if (username != null) { + conversationId = await lookupConversationWithoutServiceId({ + type: 'username', + username, + showUserNotFoundModal, + setIsFetchingUUID: noop, }); - return; } - // We will show not found modal on error - return; } - const maybeUsernameBase64 = parseUsernameBase64FromSignalDotMeHash(hash); - let username: string | undefined; - if (maybeUsernameBase64) { - username = await resolveUsernameByLinkBase64(maybeUsernameBase64); - } - - if (username) { - const convoId = await lookupConversationWithoutServiceId({ - type: 'username', - username, - showUserNotFoundModal, - setIsFetchingUUID: noop, + if (conversationId != null) { + window.reduxActions.conversations.showConversation({ + conversationId, }); - if (convoId) { - window.reduxActions.conversations.showConversation({ - conversationId: convoId, - }); - return; - } - // We will show not found modal on error return; } diff --git a/ts/util/getProvisioningUrl.ts b/ts/util/getProvisioningUrl.ts deleted file mode 100644 index 113ce94310..0000000000 --- a/ts/util/getProvisioningUrl.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2021 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import * as Bytes from '../Bytes'; - -export function getProvisioningUrl( - uuid: string, - publicKey: Uint8Array -): string { - const url = new URL('sgnl://linkdevice'); - url.searchParams.set('uuid', uuid); - url.searchParams.set('pub_key', Bytes.toBase64(publicKey)); - return url.toString(); -} diff --git a/ts/util/sgnlHref.ts b/ts/util/sgnlHref.ts deleted file mode 100644 index e422f47a37..0000000000 --- a/ts/util/sgnlHref.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright 2020 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -import type { LoggerType } from '../types/Logging'; -import { maybeParseUrl } from './url'; -import { isValidE164 } from './isValidE164'; -import { fromWebSafeBase64, toWebSafeBase64 } from './webSafeBase64'; - -const SIGNAL_HOSTS = new Set(['signal.group', 'signal.art', 'signal.me']); -const SIGNAL_DOT_ME_E164_PREFIX = 'p/'; - -function parseUrl(value: string | URL, logger: LoggerType): undefined | URL { - if (value instanceof URL) { - return value; - } - - if (typeof value === 'string') { - return maybeParseUrl(value); - } - - logger.warn('Tried to parse a sgnl:// URL but got an unexpected type'); - return undefined; -} - -export function isSgnlHref(value: string | URL, logger: LoggerType): boolean { - const url = parseUrl(value, logger); - return Boolean(url?.protocol === 'sgnl:'); -} - -export function isCaptchaHref( - value: string | URL, - logger: LoggerType -): boolean { - const url = parseUrl(value, logger); - return Boolean(url?.protocol === 'signalcaptcha:'); -} - -// A link to a signal 'action' domain with private data in path/hash/query. We could -// open a browser, but it will just link back to us. We will parse it locally instead. -export function isSignalHttpsLink( - value: string | URL, - logger: LoggerType -): boolean { - const url = parseUrl(value, logger); - return Boolean( - url && - !url.username && - !url.password && - !url.port && - url.protocol === 'https:' && - SIGNAL_HOSTS.has(url.host) && - (url.hash || url.pathname !== '/' || url.search) - ); -} - -type ParsedSgnlHref = - | { command: null; args: Map; hash: undefined } - | { command: string; args: Map; hash: string | undefined }; -export function parseSgnlHref( - href: string, - logger: LoggerType -): ParsedSgnlHref { - const url = parseUrl(href, logger); - if (!url || !isSgnlHref(url, logger)) { - return { command: null, args: new Map(), hash: undefined }; - } - - const args = new Map(); - url.searchParams.forEach((value, key) => { - if (!args.has(key)) { - args.set(key, value); - } - }); - - return { - command: url.host, - args, - hash: url.hash ? url.hash.slice(1) : undefined, - }; -} - -type ParsedCaptchaHref = { - readonly captcha: string; -}; -export function parseCaptchaHref( - href: URL | string, - logger: LoggerType -): ParsedCaptchaHref { - const url = parseUrl(href, logger); - if (!url || !isCaptchaHref(url, logger)) { - throw new Error('Not a captcha href'); - } - - return { - captcha: url.host, - }; -} - -export function parseSignalHttpsLink( - href: string, - logger: LoggerType -): ParsedSgnlHref { - const url = parseUrl(href, logger); - if (!url || !isSignalHttpsLink(url, logger)) { - return { command: null, args: new Map(), hash: undefined }; - } - - if (url.host === 'signal.art') { - const hash = url.hash.slice(1); - const hashParams = new URLSearchParams(hash); - - const args = new Map(); - hashParams.forEach((value, key) => { - if (!args.has(key)) { - args.set(key, value); - } - }); - - if (!args.get('pack_id') || !args.get('pack_key')) { - return { command: null, args: new Map(), hash: undefined }; - } - - return { - command: url.pathname.replace(/\//g, ''), - args, - hash: url.hash ? url.hash.slice(1) : undefined, - }; - } - - if (url.host === 'signal.group' || url.host === 'signal.me') { - return { - command: url.host, - args: new Map(), - hash: url.hash ? url.hash.slice(1) : undefined, - }; - } - - return { command: null, args: new Map(), hash: undefined }; -} - -export function parseE164FromSignalDotMeHash(hash: string): undefined | string { - if (!hash.startsWith(SIGNAL_DOT_ME_E164_PREFIX)) { - return; - } - - const maybeE164 = hash.slice(SIGNAL_DOT_ME_E164_PREFIX.length); - return isValidE164(maybeE164, true) ? maybeE164 : undefined; -} - -export function parseUsernameBase64FromSignalDotMeHash( - hash: string -): undefined | string { - const match = hash.match(/^eu\/([a-zA-Z0-9_-]{64})$/); - if (!match) { - return; - } - - return fromWebSafeBase64(match[1]); -} - -/** - * Converts `http://signal.group/#abc` to `https://signal.group/#abc`. Does the same for - * other Signal hosts, like signal.me. Does nothing to other URLs. Expects a valid href. - */ -export function rewriteSignalHrefsIfNecessary(href: string): string { - const resultUrl = new URL(href); - - const isHttp = resultUrl.protocol === 'http:'; - const isHttpOrHttps = isHttp || resultUrl.protocol === 'https:'; - - if (SIGNAL_HOSTS.has(resultUrl.host) && isHttpOrHttps) { - if (isHttp) { - resultUrl.protocol = 'https:'; - } - resultUrl.username = ''; - resultUrl.password = ''; - return resultUrl.href; - } - - return href; -} - -export type GenerateUsernameLinkOptionsType = Readonly<{ - short?: boolean; -}>; - -export function generateUsernameLink( - base64: string, - { short = false }: GenerateUsernameLinkOptionsType = {} -): string { - const shortVersion = `signal.me/#eu/${toWebSafeBase64(base64)}`; - if (short) { - return shortVersion; - } - return `https://${shortVersion}`; -} diff --git a/ts/util/signalRoutes.ts b/ts/util/signalRoutes.ts new file mode 100644 index 0000000000..c81029f61d --- /dev/null +++ b/ts/util/signalRoutes.ts @@ -0,0 +1,739 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import 'urlpattern-polyfill'; +// We need to use the Node.js version of `URL` because chromium's `URL` doesn't +// support custom protocols correctly. +import { URL } from 'url'; +import { z } from 'zod'; +import { strictAssert } from './assert'; + +function toUrl(input: URL | string): URL | null { + if (input instanceof URL) { + return input; + } + try { + return new URL(input); + } catch { + return null; + } +} + +/** + * List of protocols that are used by Signal routes. + */ +const SignalRouteProtocols = ['https:', 'sgnl:', 'signalcaptcha:'] as const; + +/** + * List of hostnames that are used by Signal routes. + * This doesn't include app-only routes like `linkdevice` or `verify`. + */ +const SignalRouteHostnames = [ + 'signal.me', + 'signal.group', + 'signal.link', + 'signal.art', +] as const; + +/** + * Type to help maintain {@link SignalRouteHostnames}, real hostnames should go there. + */ +type AllHostnamePatterns = + | typeof SignalRouteHostnames[number] + | 'verify' + | 'linkdevice' + | 'addstickers' + | 'art-auth' + | 'joingroup' + | 'show-conversation' + | 'start-call-lobby' + | 'show-window' + | 'set-is-presenting' + | ':captchaId' + | ''; + +/** + * Uses the `URLPattern` syntax to match URLs. + */ +type PatternString = string & { __pattern?: never }; + +type PatternInput = { + hash?: PatternString; + search?: PatternString; +}; + +type URLMatcher = (input: URL) => URLPatternResult | null; + +function _pattern( + protocol: typeof SignalRouteProtocols[number], + hostname: AllHostnamePatterns, + pathname: PatternString, + init: PatternInput +): URLMatcher { + strictAssert(protocol.endsWith(':'), 'protocol must end with `:`'); + strictAssert(!hostname.endsWith('/'), 'hostname must not end with `/`'); + strictAssert( + !(hostname === '' && pathname !== ''), + 'hostname cannot be empty string if pathname is not empty string' + ); + strictAssert( + !pathname.endsWith('/'), + 'pathname trailing slash must be optional `{/}?`' + ); + const urlPattern = new URLPattern({ + username: '', + password: '', + port: '', + // any of these can be patterns + hostname, + pathname, + search: init.search ?? '', + hash: init.hash ?? '', + } satisfies Omit, 'baseURL' | 'protocol'>); + return function match(input) { + const url = toUrl(input); + if (url == null) { + return null; + } + // We need to check protocol separately because `URL` and `URLPattern` don't + // properly support custom protocols + if (url.protocol !== protocol) { + return null; + } + return urlPattern.exec(url); + }; +} + +type PartialNullable = { + [P in keyof T]?: T[P] | null; +}; + +type RouteConfig = { + patterns: Array; + schema: z.ZodType; + parse(result: URLPatternResult): PartialNullable; + toWebUrl?(args: Args): URL; + toAppUrl?(args: Args): URL; +}; + +type SignalRoute = { + isMatch(input: URL | string): boolean; + fromUrl(input: URL | string): RouteResult | null; + toWebUrl(args: Args): URL; + toAppUrl(args: Args): URL; +}; + +type RouteResult = { + key: Key; + args: Args; +}; + +let _routeCount = 0; + +function _route( + key: Key, + config: RouteConfig +): SignalRoute { + _routeCount += 1; + return { + isMatch(input) { + const url = toUrl(input); + if (url == null) { + return false; + } + return config.patterns.some(matcher => { + return matcher(url) != null; + }); + }, + fromUrl(input) { + const url = toUrl(input); + if (url == null) { + return null; + } + for (const matcher of config.patterns) { + const result = matcher(url); + if (result) { + return { + key, + args: config.schema.parse(config.parse(result)), + }; + } + } + return null; + }, + toWebUrl(args) { + if (config.toWebUrl) { + return config.toWebUrl(config.schema.parse(args)); + } + throw new Error('Route does not support web URLs'); + }, + toAppUrl(args) { + if (config.toAppUrl) { + return config.toAppUrl(config.schema.parse(args)); + } + throw new Error('Route does not support app URLs'); + }, + }; +} + +const paramSchema = z.string().min(1); +const optionalParamSchema = paramSchema.nullish().default(null); + +/** + * signal.me by phone number + * @example + * ```ts + * contactByPhoneNumberRoute.toWebUrl({ + * phoneNumber: "+1234567890", + * }) + * // URL { "https://signal.me/#p/+1234567890" } + * ``` + */ +export const contactByPhoneNumberRoute = _route('contactByPhoneNumber', { + patterns: [ + _pattern('https:', 'signal.me', '{/}?', { hash: 'p/:phoneNumber' }), + _pattern('sgnl:', 'signal.me', '{/}?', { hash: 'p/:phoneNumber' }), + ], + schema: z.object({ + phoneNumber: paramSchema, // E164 (with +) + }), + parse(result) { + return { + phoneNumber: paramSchema.parse(result.hash.groups.phoneNumber), + }; + }, + toWebUrl(args) { + return new URL(`https://signal.me/#p/${args.phoneNumber}`); + }, + toAppUrl(args) { + return new URL(`sgnl://signal.me/#p/${args.phoneNumber}`); + }, +}); + +/** + * signal.me by encrypted username + * @example + * ```ts + * contactByEncryptedUsernameRoute.toWebUrl({ + * encryptedUsername: "123", + * }) + * // URL { "https://signal.me/#eu/123" } + * ``` + */ +export const contactByEncryptedUsernameRoute = _route( + 'contactByEncryptedUsername', + { + patterns: [ + _pattern('https:', 'signal.me', '{/}?', { + hash: 'eu/:encryptedUsername', + }), + _pattern('sgnl:', 'signal.me', '{/}?', { hash: 'eu/:encryptedUsername' }), + ], + schema: z.object({ + encryptedUsername: paramSchema, // base64url (32 bytes of entropy + 16 bytes of big-endian UUID) + }), + parse(result) { + return { + encryptedUsername: result.hash.groups.encryptedUsername, + }; + }, + toWebUrl(args) { + return new URL(`https://signal.me/#eu/${args.encryptedUsername}`); + }, + toAppUrl(args) { + return new URL(`sgnl://signal.me/#eu/${args.encryptedUsername}`); + }, + } +); + +/** + * Group invites + * @example + * ```ts + * groupInvitesRoute.toWebUrl({ + * inviteCode: "123", + * }) + * // URL { "https://signal.group/#123" } + * ``` + */ +export const groupInvitesRoute = _route('groupInvites', { + patterns: [ + _pattern('https:', 'signal.group', '{/}?', { + hash: ':inviteCode([^\\/]+)', + }), + _pattern('sgnl:', 'signal.group', '{/}?', { + hash: ':inviteCode([^\\/]+)', + }), + _pattern('sgnl:', 'joingroup', '{/}?', { hash: ':inviteCode([^\\/]+)' }), + ], + schema: z.object({ + inviteCode: paramSchema, // base64url (GroupInviteLink proto) + }), + parse(result) { + return { + inviteCode: result.hash.groups.inviteCode, + }; + }, + toWebUrl(args) { + return new URL(`https://signal.group/#${args.inviteCode}`); + }, + toAppUrl(args) { + return new URL(`sgnl://signal.group/#${args.inviteCode}`); + }, +}); + +/** + * Device linking QR code + * @example + * ```ts + * linkDeviceRoute.toAppUrl({ + * uuid: "123", + * pubKey: "abc", + * }) + * // URL { "sgnl://linkdevice?uuid=123&pub_key=abc" } + * ``` + */ +export const linkDeviceRoute = _route('linkDevice', { + patterns: [_pattern('sgnl:', 'linkdevice', '{/}?', { search: ':params' })], + schema: z.object({ + uuid: paramSchema, // base64url? + pubKey: paramSchema, // percent-encoded base64 (with padding) of PublicKey with type byte included + }), + parse(result) { + const params = new URLSearchParams(result.search.groups.params); + return { + uuid: params.get('uuid'), + pubKey: params.get('pub_key'), + }; + }, + toAppUrl(args) { + const params = new URLSearchParams({ + uuid: args.uuid, + pub_key: args.pubKey, + }); + return new URL(`sgnl://linkdevice?${params.toString()}`); + }, +}); + +/** + * Captchas + * @example + * ```ts + * captchaRoute.toAppUrl({ + * captchaId: "123", + * }) + * // URL { "signalcaptcha://123" } + * ``` + */ +export const captchaRoute = _route('captcha', { + patterns: [_pattern('signalcaptcha:', ':captchaId', '', {})], + schema: z.object({ + captchaId: paramSchema, // opaque + }), + parse(result) { + return { + captchaId: result.hostname.groups.captchaId, + }; + }, + toAppUrl(args) { + return new URL(`signalcaptcha://${args.captchaId}`); + }, +}); + +/** + * Join a call with a link. + * @example + * ```ts + * linkCallRoute.toWebUrl({ + * key: "123", + * }) + * // URL { "https://signal.link/call#key=123" } + */ +export const linkCallRoute = _route('linkCall', { + patterns: [ + _pattern('https:', 'signal.link', '/call{/}?', { hash: ':params' }), + _pattern('sgnl:', 'signal.link', '/call{/}?', { hash: ':params' }), + ], + schema: z.object({ + key: paramSchema, // ConsonantBase16 + }), + parse(result) { + const params = new URLSearchParams(result.hash.groups.params); + return { + key: params.get('key'), + }; + }, + toWebUrl(args) { + const params = new URLSearchParams({ key: args.key }); + return new URL(`https://signal.link/call#${params.toString()}`); + }, + toAppUrl(args) { + const params = new URLSearchParams({ key: args.key }); + return new URL(`sgnl://signal.link/call#${params.toString()}`); + }, +}); + +/** + * Sticker packs + * @example + * ```ts + * artAddStickersRoute.toWebUrl({ + * packId: "123", + * packKey: "abc", + * }) + * // URL { "https://signal.art/addstickers#pack_id=123&pack_key=abc" } + * ``` + */ +export const artAddStickersRoute = _route('artAddStickers', { + patterns: [ + _pattern('https:', 'signal.art', '/addstickers{/}?', { hash: ':params' }), + _pattern('sgnl:', 'addstickers', '{/}?', { search: ':params' }), + ], + schema: z.object({ + packId: paramSchema, // hexadecimal + packKey: paramSchema, // hexadecimal + }), + parse(result) { + const params = new URLSearchParams( + result.hash.groups.params ?? result.search.groups.params + ); + return { + packId: params.get('pack_id'), + packKey: params.get('pack_key'), + }; + }, + toWebUrl(args) { + const params = new URLSearchParams({ + pack_id: args.packId, + pack_key: args.packKey, + }); + return new URL(`https://signal.art/addstickers#${params.toString()}`); + }, + toAppUrl(args) { + const params = new URLSearchParams({ + pack_id: args.packId, + pack_key: args.packKey, + }); + return new URL(`sgnl://addstickers?${params.toString()}`); + }, +}); + +/** + * Art Service Authentication + * @example + * ```ts + * artAuthRoute.toAppUrl({ + * token: "123", + * pubKey: "abc", + * }) + * // URL { "sgnl://art-auth?token=123&pub_key=abc" } + */ +export const artAuthRoute = _route('artAuth', { + patterns: [_pattern('sgnl:', 'art-auth', '{/}?', { search: ':params' })], + schema: z.object({ + token: paramSchema, // opaque + pubKey: paramSchema, // base64url + }), + parse(result) { + const params = new URLSearchParams(result.search.groups.params); + return { + token: params.get('token'), + pubKey: params.get('pub_key'), + }; + }, + toAppUrl(args) { + const params = new URLSearchParams({ + token: args.token, + pub_key: args.pubKey, + }); + return new URL(`sgnl://art-auth?${params.toString()}`); + }, +}); + +/** + * Show a conversation + * @example + * ```ts + * showConversationRoute.toAppUrl({ + * conversationId: "123", + * messageId: "abc", + * storyId: "def", + * }) + * // URL { "sgnl://show-conversation?conversationId=123&messageId=abc&storyId=def" } + * ``` + */ +export const showConversationRoute = _route('showConversation', { + patterns: [ + _pattern('sgnl:', 'show-conversation', '{/}?', { search: ':params' }), + ], + schema: z.object({ + conversationId: paramSchema, + messageId: optionalParamSchema, + storyId: optionalParamSchema, + }), + parse(result) { + const params = new URLSearchParams(result.search.groups.params); + return { + conversationId: params.get('conversationId'), + messageId: params.get('messageId'), + storyId: params.get('storyId'), + }; + }, + toAppUrl(args) { + const params = new URLSearchParams({ + conversationId: args.conversationId, + }); + if (args.messageId != null) { + params.set('messageId', args.messageId); + } + if (args.storyId != null) { + params.set('storyId', args.storyId); + } + return new URL(`sgnl://show-conversation?${params.toString()}`); + }, +}); + +/** + * Start a call lobby + * @example + * ```ts + * startCallLobbyRoute.toAppUrl({ + * conversationId: "123", + * }) + * // URL { "sgnl://start-call-lobby?conversationId=123" } + * ``` + */ +export const startCallLobbyRoute = _route('startCallLobby', { + patterns: [ + _pattern('sgnl:', 'start-call-lobby', '{/}?', { search: ':params' }), + ], + schema: z.object({ + conversationId: paramSchema, + }), + parse(result) { + const params = new URLSearchParams(result.search.groups.params); + return { + conversationId: params.get('conversationId'), + }; + }, + toAppUrl(args) { + const params = new URLSearchParams({ + conversationId: args.conversationId, + }); + return new URL(`sgnl://start-call-lobby?${params.toString()}`); + }, +}); + +/** + * Show window + * @example + * ```ts + * showWindowRoute.toAppUrl({}) + * // URL { "sgnl://show-window" } + */ +export const showWindowRoute = _route('showWindow', { + patterns: [_pattern('sgnl:', 'show-window', '{/}?', {})], + schema: z.object({}), + parse() { + return {}; + }, + toAppUrl() { + return new URL('sgnl://show-window'); + }, +}); + +/** + * Set is presenting + * @example + * ```ts + * setIsPresentingRoute.toAppUrl({}) + * // URL { "sgnl://set-is-presenting" } + * ``` + */ +export const setIsPresentingRoute = _route('setIsPresenting', { + patterns: [_pattern('sgnl:', 'set-is-presenting', '{/}?', {})], + schema: z.object({}), + parse() { + return {}; + }, + toAppUrl() { + return new URL('sgnl://set-is-presenting'); + }, +}); + +/** + * Should include all routes for matching purposes. + * @internal + */ +const _allSignalRoutes = [ + contactByPhoneNumberRoute, + contactByEncryptedUsernameRoute, + groupInvitesRoute, + linkDeviceRoute, + captchaRoute, + linkCallRoute, + artAddStickersRoute, + artAuthRoute, + showConversationRoute, + startCallLobbyRoute, + showWindowRoute, + setIsPresentingRoute, +] as const; + +strictAssert( + _allSignalRoutes.length === _routeCount, + 'Forgot to add route to routes list' +); + +/** + * A parsed route with the `key` of the route and its parsed `args`. + * @example + * ```ts + * parseSignalRoute(new URL("https://signal.me/#p/+1234567890")) + * // { + * // key: "contactByPhoneNumber", + * // args: { phoneNumber: "+1234567890" }, + * // } + * ``` + */ +export type ParsedSignalRoute = NonNullable< + ReturnType +>; + +/** @internal */ +type MatchedSignalRoute = { + route: SignalRoute; + parsed: ParsedSignalRoute; +}; + +/** @internal */ +function _matchSignalRoute(input: URL | string): MatchedSignalRoute | null { + const url = toUrl(input); + if (url == null) { + return null; + } + for (const route of _allSignalRoutes) { + const parsed = route.fromUrl(url); + if (parsed != null) { + return { route, parsed }; + } + } + return null; +} + +/** @internal */ +function _normalizeUrl(url: URL | string): URL | null { + const newUrl = toUrl(url); + if (newUrl == null) { + return null; + } + newUrl.port = ''; + newUrl.username = ''; + newUrl.password = ''; + if (newUrl.protocol === 'http:') { + newUrl.protocol = 'https:'; + } + return newUrl; +} + +/** + * Check if a URL matches a route. + * @example + * ```ts + * isSignalRoute(new URL("https://signal.me/#p/+1234567890")) // true + * isSignalRoute(new URL("sgnl://signal.me/#p/+1234567890")) // true + * isSignalRoute(new URL("https://signal.me")) // false + * isSignalRoute(new URL("https://example.com")) // false + * ``` + */ +export function isSignalRoute(input: URL | string): boolean { + return _matchSignalRoute(input) != null; +} + +/** + * Maybe parse a URL into a matching route with the 'key' of the route and its + * parsed args. + * If it we can't match it to a route, return null. + * @example + * ```ts + * parseSignalRoute(new URL("https://signal.me/#p/+1234567890")) + * // { key: "contactByPhoneNumber", args: { phoneNumber: "+1234567890" } } + * parseSignalRoute(new URL("sgnl://signal.me/#p/+1234567890")) + * // { key: "contactByPhoneNumber", args: { phoneNumber: "+1234567890" } } + * parseSignalRoute(new URL("https://example.com")) + * // null + * ``` + */ +export function parseSignalRoute( + input: URL | string +): ParsedSignalRoute | null { + return _matchSignalRoute(input)?.parsed ?? null; +} + +/** + * Maybe normalize a URL into a matching route URL. + * If it we can't match it to a route, return null. + * @example + * ```ts + * toSignalRouteUrl(new URL("http://username:password@signal.me/#p/+1234567890")) + * // URL { "https://signal.me/#p/+1234567890" } + * toSignalRouteUrl(new URL("sgnl://signal.me/#p/+1234567890")) + * // URL { "sgnl://signal.me/#p/+1234567890" } + * toSignalRouteUrl(new URL("https://example.com")) + * // null + * ``` + */ +export function toSignalRouteUrl(input: URL | string): URL | null { + const normalizedUrl = _normalizeUrl(input); + if (normalizedUrl == null) { + return null; + } + return _matchSignalRoute(normalizedUrl) != null ? normalizedUrl : null; +} + +/** + * Maybe normalize a URL into a matching route **App** URL. + * If it we can't match it to a route, return null. + * @example + * ```ts + * toSignalRouteAppUrl(new URL("https://signal.me/#p/+1234567890")) + * // URL { "sgnl://signal.me/#p/+1234567890" } + * toSignalRouteAppUrl(new URL("https://example.com")) + * // null + * ``` + */ +export function toSignalRouteAppUrl(input: URL | string): URL | null { + const normalizedUrl = _normalizeUrl(input); + if (normalizedUrl == null) { + return null; + } + const match = _matchSignalRoute(normalizedUrl); + try { + return match?.route.toAppUrl(match.parsed.args) ?? null; + } catch { + return null; + } +} + +/** + * Maybe normalize a URL into a matching route **Web** URL. + * If it we can't match it to a route, return null. + * @example + * ```ts + * toSignalRouteWebUrl(new URL("sgnl://signal.me/#p/+1234567890")) + * // URL { "https://signal.me/#p/+1234567890" } + * toSignalRouteWebUrl(new URL("https://example.com")) + * // null + * ``` + */ +export function toSignalRouteWebUrl(input: URL | string): URL | null { + const normalizedUrl = _normalizeUrl(input); + if (normalizedUrl == null) { + return null; + } + const match = _matchSignalRoute(normalizedUrl); + try { + return match?.route.toWebUrl(match.parsed.args) ?? null; + } catch { + return null; + } +} diff --git a/ts/util/url.ts b/ts/util/url.ts index 1f12dbcf29..602840d341 100644 --- a/ts/util/url.ts +++ b/ts/util/url.ts @@ -5,7 +5,7 @@ export function maybeParseUrl(value: string): undefined | URL { if (typeof value === 'string') { try { return new URL(value); - } catch (err) { + } catch { /* Errors are ignored. */ } } diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index 8739404251..5278bcffac 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -289,24 +289,18 @@ ipc.on('delete-all-data', async () => { }); ipc.on('show-sticker-pack', (_event, info) => { - const { packId, packKey } = info; - const { showStickerPack } = window.Events; - if (showStickerPack) { - showStickerPack(packId, packKey); - } + window.Events.showStickerPack?.(info.packId, info.packKey); }); ipc.on('show-group-via-link', (_event, info) => { - const { hash } = info; - const { showGroupViaLink } = window.Events; - if (showGroupViaLink) { - void showGroupViaLink(hash); - } + strictAssert(typeof info.value === 'string', 'Got an invalid value over IPC'); + drop(window.Events.showGroupViaLink?.(info.value)); }); ipc.on('open-art-creator', () => { drop(window.Events.openArtCreator()); }); + window.openArtCreator = ({ username, password, @@ -318,8 +312,7 @@ window.openArtCreator = ({ }; ipc.on('authorize-art-creator', (_event, info) => { - const { token, pubKeyBase64 } = info; - window.Events.authorizeArtCreator?.({ token, pubKeyBase64 }); + window.Events.authorizeArtCreator?.(info); }); ipc.on('start-call-lobby', (_event, { conversationId }) => { @@ -328,9 +321,11 @@ ipc.on('start-call-lobby', (_event, { conversationId }) => { isVideoCall: true, }); }); + ipc.on('show-window', () => { window.IPC.showWindow(); }); + ipc.on('set-is-presenting', () => { window.reduxActions?.calling?.setPresenting(); }); @@ -345,12 +340,13 @@ ipc.on( } ); ipc.on('show-conversation-via-signal.me', (_event, info) => { - const { hash } = info; - strictAssert(typeof hash === 'string', 'Got an invalid hash over IPC'); + const { kind, value } = info; + strictAssert(typeof kind === 'string', 'Got an invalid kind over IPC'); + strictAssert(typeof value === 'string', 'Got an invalid value over IPC'); const { showConversationViaSignalDotMe } = window.Events; if (showConversationViaSignalDotMe) { - void showConversationViaSignalDotMe(hash); + void showConversationViaSignalDotMe(kind, value); } }); diff --git a/yarn.lock b/yarn.lock index 0ef14bdcb8..4c9d830b87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19442,6 +19442,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +urlpattern-polyfill@9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/urlpattern-polyfill/-/urlpattern-polyfill-9.0.0.tgz#bc7e386bb12fd7898b58d1509df21d3c29ab3460" + integrity sha512-WHN8KDQblxd32odxeIgo83rdVDE2bvdkb86it7bMhYZwWKJz0+O0RK/eZiHYnM+zgt/U7hAHOlCQGfjjvSkw2g== + use-callback-ref@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/use-callback-ref/-/use-callback-ref-1.3.0.tgz#772199899b9c9a50526fedc4993fc7fa1f7e32d5"