// 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'; import * as log from '../logging/log'; import * as Errors from '../types/errors'; 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, url: URL): 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) { let rawArgs; try { rawArgs = config.parse(result, url); } catch (error) { log.error( `Failed to parse route ${key} from URL ${url.toString()}`, Errors.toLogFormat(error) ); return null; } const parseResult = config.schema.safeParse(rawArgs); if (parseResult.success) { const args = parseResult.data; return { key, args, }; } log.error( `Failed to parse route ${key} from URL ${url.toString()}`, parseResult.error.format() ); return null; } } 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', { // needs `(.+)` to capture `.` in hostname patterns: [_pattern('signalcaptcha:', ':captchaId(.+)', '{/}?', {})], schema: z.object({ captchaId: paramSchema, // opaque }), parse(_result, url) { return { captchaId: url.hostname, }; }, 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; } }