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:
parent
f832b018fc
commit
e10ae03bb7
8 changed files with 338 additions and 33 deletions
2
js/modules/link_previews.d.ts
vendored
2
js/modules/link_previews.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
23
main.js
|
@ -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');
|
||||
|
|
45
ts/groups.ts
45
ts/groups.ts
|
@ -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) {
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>() };
|
||||
}
|
||||
|
|
|
@ -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 GetLinkPreviewResult = {
|
||||
title: string;
|
||||
url: string;
|
||||
image: {
|
||||
type GetLinkPreviewImageResult = {
|
||||
data: ArrayBuffer;
|
||||
size: number;
|
||||
contentType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type GetLinkPreviewResult = {
|
||||
title: string;
|
||||
url: string;
|
||||
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.
|
||||
|
|
Loading…
Reference in a new issue