Create group link previews; don't open Signal links in browser first; allow ephemeral download of previously-error'd pack

This commit is contained in:
Scott Nonnenberg 2021-02-10 14:39:26 -08:00 committed by GitHub
parent f832b018fc
commit e10ae03bb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 338 additions and 33 deletions

View File

@ -7,6 +7,8 @@ export function findLinks(text: string, caretLocation?: number): Array<string>;
export function getDomain(href: string): string;
export function isGroupLink(href: string): boolean;
export function isLinkSneaky(link: string): boolean;
export function isStickerPack(href: string): boolean;

View File

@ -12,6 +12,7 @@ const linkify = LinkifyIt();
module.exports = {
findLinks,
getDomain,
isGroupLink,
isLinkSafeToPreview,
isLinkSneaky,
isStickerPack,
@ -34,6 +35,10 @@ function isStickerPack(link) {
return (link || '').startsWith('https://signal.art/addstickers/');
}
function isGroupLink(link) {
return (link || '').startsWith('https://signal.group/');
}
function findLinks(text, caretLocation) {
const haveCaretLocation = isNumber(caretLocation);
const textLength = text ? text.length : 0;

View File

@ -354,7 +354,12 @@ async function downloadEphemeralPack(packId, packKey) {
} = getReduxStickerActions();
const existingPack = getStickerPack(packId);
if (existingPack) {
if (
existingPack &&
(existingPack.status === 'downloaded' ||
existingPack.status === 'installed' ||
existingPack.status === 'pending')
) {
log.warn(
`Ephemeral download for pack ${redactPackId(
packId

23
main.js
View File

@ -99,7 +99,12 @@ const {
const { installPermissionsHandler } = require('./app/permissions');
const OS = require('./ts/OS');
const { isBeta } = require('./ts/util/version');
const { isSgnlHref, parseSgnlHref } = require('./ts/util/sgnlHref');
const {
isSgnlHref,
isSignalHttpsLink,
parseSgnlHref,
parseSignalHttpsLink,
} = require('./ts/util/sgnlHref');
const {
toggleMaximizedBrowserWindow,
} = require('./ts/util/toggleMaximizedBrowserWindow');
@ -227,6 +232,11 @@ async function handleUrl(event, target) {
const { protocol, hostname } = url.parse(target);
const isDevServer = config.enableHttp && hostname === 'localhost';
// We only want to specially handle urls that aren't requesting the dev server
if (isSgnlHref(target) || isSignalHttpsLink(target)) {
handleSgnlHref(target);
return;
}
if ((protocol === 'http:' || protocol === 'https:') && !isDevServer) {
try {
await shell.openExternal(target);
@ -1476,7 +1486,16 @@ function getIncomingHref(argv) {
}
function handleSgnlHref(incomingHref) {
const { command, args, hash } = parseSgnlHref(incomingHref, logger);
let command;
let args;
let hash;
if (isSgnlHref(incomingHref)) {
({ command, args, hash } = parseSgnlHref(incomingHref, logger));
} else if (isSignalHttpsLink(incomingHref)) {
({ command, args, hash } = parseSignalHttpsLink(incomingHref, logger));
}
if (command === 'addstickers' && mainWindow && mainWindow.webContents) {
console.log('Opening sticker pack from sgnl protocol link');
const packId = args.get('pack_id');

View File

@ -3809,6 +3809,30 @@ async function applyGroupChange({
};
}
export async function decryptGroupAvatar(
avatarKey: string,
secretParamsBase64: string
): Promise<ArrayBuffer> {
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error(
'decryptGroupAvatar: textsecure.messaging is not available!'
);
}
const ciphertext = await sender.getGroupAvatar(avatarKey);
const clientZkGroupCipher = getClientZkGroupCipher(secretParamsBase64);
const plaintext = decryptGroupBlob(clientZkGroupCipher, ciphertext);
const blob = window.textsecure.protobuf.GroupAttributeBlob.decode(plaintext);
if (blob.content !== 'avatar') {
throw new Error(
`decryptGroupAvatar: Returned blob had incorrect content: ${blob.content}`
);
}
return blob.avatar.toArrayBuffer();
}
// Ovewriting result.avatar as part of functionality
/* eslint-disable no-param-reassign */
export async function applyNewAvatar(
@ -3825,30 +3849,11 @@ export async function applyNewAvatar(
// Group has avatar; has it changed?
if (newAvatar && (!result.avatar || result.avatar.url !== newAvatar)) {
const sender = window.textsecure.messaging;
if (!sender) {
throw new Error(
'applyNewAvatar: textsecure.messaging is not available!'
);
}
if (!result.secretParams) {
throw new Error('applyNewAvatar: group was missing secretParams!');
}
const ciphertext = await sender.getGroupAvatar(newAvatar);
const clientZkGroupCipher = getClientZkGroupCipher(result.secretParams);
const plaintext = decryptGroupBlob(clientZkGroupCipher, ciphertext);
const blob = window.textsecure.protobuf.GroupAttributeBlob.decode(
plaintext
);
if (blob.content !== 'avatar') {
throw new Error(
`applyNewAvatar: Returned blob had incorrect content: ${blob.content}`
);
}
const data = blob.avatar.toArrayBuffer();
const data = await decryptGroupAvatar(newAvatar, result.secretParams);
const hash = await computeHash(data);
if (result.avatar && result.avatar.path && result.avatar.hash !== hash) {

View File

@ -5,7 +5,12 @@ import { assert } from 'chai';
import Sinon from 'sinon';
import { LoggerType } from '../../types/Logging';
import { isSgnlHref, parseSgnlHref } from '../../util/sgnlHref';
import {
isSgnlHref,
isSignalHttpsLink,
parseSgnlHref,
parseSignalHttpsLink,
} from '../../util/sgnlHref';
function shouldNeverBeCalled() {
assert.fail('This should never be called');
@ -83,6 +88,67 @@ describe('sgnlHref', () => {
});
});
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', explodingLogger));
assert.isFalse(
isSignalHttpsLink(
'sgnl://signal.art/addstickers/?pack_id=abc',
explodingLogger
)
);
assert.isFalse(
isSignalHttpsLink('signal://signal.group', explodingLogger)
);
});
it('returns true if the protocol is "https:"', () => {
assert.isTrue(isSignalHttpsLink('https://signal.group', explodingLogger));
assert.isTrue(isSignalHttpsLink('https://signal.art', explodingLogger));
assert.isTrue(isSignalHttpsLink('HTTPS://signal.art', explodingLogger));
});
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', explodingLogger)
);
});
it('accepts URL objects', () => {
const invalid = new URL('sgnl://example.com');
assert.isFalse(isSignalHttpsLink(invalid, explodingLogger));
const valid = new URL('https://signal.art');
assert.isTrue(isSignalHttpsLink(valid, explodingLogger));
});
});
describe('parseSgnlHref', () => {
it('returns a null command for invalid URLs', () => {
['', 'sgnl', 'https://example/?foo=bar'].forEach(href => {
@ -188,4 +254,47 @@ describe('sgnlHref', () => {
);
});
});
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<never, never>(),
});
});
});
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<never, never>(),
hash: 'data',
}
);
});
});
});

View File

@ -23,6 +23,21 @@ export function isSgnlHref(value: string | URL, logger: LoggerType): boolean {
return url !== null && url.protocol === 'sgnl:';
}
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:' &&
(url.host === 'signal.group' || url.host === 'signal.art')
);
}
type ParsedSgnlHref =
| { command: null; args: Map<never, never> }
| { command: string; args: Map<string, string>; hash: string | undefined };
@ -48,3 +63,45 @@ export function parseSgnlHref(
hash: url.hash ? url.hash.slice(1) : undefined,
};
}
export function parseSignalHttpsLink(
href: string,
logger: LoggerType
): ParsedSgnlHref {
const url = parseUrl(href, logger);
if (!url || !isSignalHttpsLink(url, logger)) {
return { command: null, args: new Map<never, never>() };
}
if (url.host === 'signal.art') {
const hash = url.hash.slice(1);
const hashParams = new URLSearchParams(hash);
const args = new Map<string, string>();
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<never, never>() };
}
return {
command: url.pathname.replace(/\//g, ''),
args,
hash: url.hash ? url.hash.slice(1) : undefined,
};
}
if (url.host === 'signal.group') {
return {
command: url.host,
args: new Map<string, string>(),
hash: url.hash ? url.hash.slice(1) : undefined,
};
}
return { command: null, args: new Map<never, never>() };
}

View File

@ -12,16 +12,18 @@ type GroupV2PendingMemberType = import('../model-types.d').GroupV2PendingMemberT
type MediaItemType = import('../components/LightboxGallery').MediaItemType;
type MessageType = import('../state/ducks/conversations').MessageType;
type GetLinkPreviewImageResult = {
data: ArrayBuffer;
size: number;
contentType: string;
width?: number;
height?: number;
};
type GetLinkPreviewResult = {
title: string;
url: string;
image: {
data: ArrayBuffer;
size: number;
contentType: string;
width: number;
height: number;
};
image?: GetLinkPreviewImageResult;
description: string | null;
date: number | null;
};
@ -3580,7 +3582,10 @@ Whisper.ConversationView = Whisper.View.extend({
this.renderLinkPreview();
},
async getStickerPackPreview(url: any) {
async getStickerPackPreview(
url: string,
abortSignal: any
): Promise<null | GetLinkPreviewResult> {
const isPackDownloaded = (pack: any) =>
pack && (pack.status === 'downloaded' || pack.status === 'installed');
const isPackValid = (pack: any) =>
@ -3604,7 +3609,12 @@ Whisper.ConversationView = Whisper.View.extend({
await window.Signal.Stickers.downloadEphemeralPack(id, keyBase64);
}
if (abortSignal.aborted) {
return null;
}
const pack = window.Signal.Stickers.getStickerPack(id);
if (!isPackValid(pack)) {
return null;
}
@ -3619,6 +3629,10 @@ Whisper.ConversationView = Whisper.View.extend({
? await window.Signal.Migrations.readTempData(sticker.path)
: await window.Signal.Migrations.readStickerData(sticker.path);
if (abortSignal.aborted) {
return null;
}
return {
title,
url,
@ -3628,6 +3642,8 @@ Whisper.ConversationView = Whisper.View.extend({
size: data.byteLength,
contentType: 'image/webp',
},
description: null,
date: null,
};
} catch (error) {
window.log.error(
@ -3642,12 +3658,99 @@ Whisper.ConversationView = Whisper.View.extend({
}
},
async getGroupPreview(
url: string,
abortSignal: any
): Promise<null | GetLinkPreviewResult> {
let urlObject;
try {
urlObject = new URL(url);
} catch (err) {
return null;
}
const { hash } = urlObject;
if (!hash) {
return null;
}
const groupData = hash.slice(1);
const {
inviteLinkPassword,
masterKey,
} = window.Signal.Groups.parseGroupLink(groupData);
const fields = window.Signal.Groups.deriveGroupFields(
window.Signal.Crypto.base64ToArrayBuffer(masterKey)
);
const id = window.Signal.Crypto.arrayBufferToBase64(fields.id);
const logId = `groupv2(${id})`;
const secretParams = window.Signal.Crypto.arrayBufferToBase64(
fields.secretParams
);
window.log.info(`getGroupPreview/${logId}: Fetching pre-join state`);
const result = await window.Signal.Groups.getPreJoinGroupInfo(
inviteLinkPassword,
masterKey
);
if (abortSignal.aborted) {
return null;
}
const title =
window.Signal.Groups.decryptGroupTitle(result.title, secretParams) ||
window.i18n('unknownGroup');
const description =
result.memberCount === 1 || result.memberCount === undefined
? window.i18n('GroupV2--join--member-count--single')
: window.i18n('GroupV2--join--member-count--multiple', {
count: result.memberCount.toString(),
});
let image: undefined | GetLinkPreviewImageResult;
if (result.avatar) {
try {
const data = await window.Signal.Groups.decryptGroupAvatar(
result.avatar,
secretParams
);
image = {
data,
size: data.byteLength,
contentType: 'image/jpeg',
};
} catch (error) {
const errorString = error && error.stack ? error.stack : error;
window.log.error(
`getGroupPreview/${logId}: Failed to fetch avatar ${errorString}`
);
}
}
if (abortSignal.aborted) {
return null;
}
return {
title,
description,
url,
image,
date: null,
};
},
async getPreview(
url: string,
abortSignal: any
): Promise<null | GetLinkPreviewResult> {
if (window.Signal.LinkPreviews.isStickerPack(url)) {
return this.getStickerPackPreview(url);
return this.getStickerPackPreview(url, abortSignal);
}
if (window.Signal.LinkPreviews.isGroupLink(url)) {
return this.getGroupPreview(url, abortSignal);
}
// This is already checked elsewhere, but we want to be extra-careful.