Co-authored-by: scott@signal.org
Co-authored-by: ken@signal.org
This commit is contained in:
Ken Powers 2019-05-16 15:32:11 -07:00 committed by Scott Nonnenberg
parent 8c8856785b
commit 29de50c12a
100 changed files with 7572 additions and 693 deletions

View file

@ -296,6 +296,24 @@
// Shut down the data interface cleanly
await window.Signal.Data.shutdown();
},
installStickerPack: async (id, key) => {
const status = window.Signal.Stickers.getStickerPackStatus(id);
if (status === 'installed') {
return;
}
if (status === 'advertised') {
await window.reduxActions.stickers.installStickerPack(id, key, {
fromSync: true,
});
} else {
await window.Signal.Stickers.downloadStickerPack(id, key, {
finalStatus: 'installed',
});
}
},
};
const currentVersion = window.getVersion();
@ -303,18 +321,23 @@
newVersion = !lastVersion || currentVersion !== lastVersion;
await storage.put('version', currentVersion);
if (newVersion) {
if (
lastVersion &&
window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5')
) {
await window.Signal.Logs.deleteAll();
window.restart();
}
if (newVersion && lastVersion) {
window.log.info(
`New version detected: ${currentVersion}; previous: ${lastVersion}`
);
if (window.isBeforeVersion(lastVersion, 'v1.25.0')) {
// Stickers flags
await Promise.all([
storage.put('showStickersIntroduction', true),
storage.put('showStickerPickerHint', true),
]);
}
if (window.isBeforeVersion(lastVersion, 'v1.15.0-beta.5')) {
await window.Signal.Logs.deleteAll();
window.restart();
}
}
if (isIndexedDBPresent) {
@ -395,6 +418,7 @@
try {
await Promise.all([
ConversationController.load(),
Signal.Stickers.load(),
textsecure.storage.protocol.hydrateCaches(),
]);
} catch (error) {
@ -418,7 +442,11 @@
conversations: {
conversationLookup: Signal.Util.makeLookup(conversations, 'id'),
},
items: storage.getItemsState(),
stickers: Signal.Stickers.getInitialState(),
user: {
attachmentsPath: window.baseAttachmentsPath,
stickersPath: window.baseStickersPath,
regionCode: window.storage.get('regionCode'),
ourNumber: textsecure.storage.user.getNumber(),
i18n: window.i18n,
@ -437,10 +465,18 @@
Signal.State.Ducks.conversations.actions,
store.dispatch
);
actions.items = Signal.State.bindActionCreators(
Signal.State.Ducks.items.actions,
store.dispatch
);
actions.user = Signal.State.bindActionCreators(
Signal.State.Ducks.user.actions,
store.dispatch
);
actions.stickers = Signal.State.bindActionCreators(
Signal.State.Ducks.stickers.actions,
store.dispatch
);
const {
conversationAdded,
@ -759,6 +795,7 @@
messageReceiver.addEventListener('progress', onProgress);
messageReceiver.addEventListener('configuration', onConfiguration);
messageReceiver.addEventListener('typing', onTyping);
messageReceiver.addEventListener('sticker-pack', onStickerPack);
window.Signal.AttachmentDownloads.start({
getMessageReceiver: () => messageReceiver,
@ -770,6 +807,10 @@
PASSWORD
);
if (connectCount === 1) {
window.Signal.Stickers.downloadQueuedPacks();
}
// On startup after upgrading to a new version, request a contact sync
// (but only if we're not the primary device)
if (
@ -831,11 +872,34 @@
Whisper.events.trigger('contactsync');
});
const ourNumber = textsecure.storage.user.getNumber();
const { wrap, sendOptions } = ConversationController.prepareForSend(
ourNumber,
{ syncMessage: true }
);
const installedStickerPacks = window.Signal.Stickers.getInstalledStickerPacks();
if (installedStickerPacks.length) {
const operations = installedStickerPacks.map(pack => ({
packId: pack.id,
packKey: pack.key,
installed: true,
}));
wrap(
window.textsecure.messaging.sendStickerPackSync(
operations,
sendOptions
)
).catch(error => {
window.log.error(
'Failed to send installed sticker packs via sync message',
error && error.stack ? error.stack : error
);
});
}
if (Whisper.Import.isComplete()) {
const { wrap, sendOptions } = ConversationController.prepareForSend(
textsecure.storage.user.getNumber(),
{ syncMessage: true }
);
wrap(
textsecure.messaging.sendRequestConfigurationSyncMessage(sendOptions)
).catch(error => {
@ -942,6 +1006,42 @@
}
}
async function onStickerPack(ev) {
const packs = ev.stickerPacks || [];
packs.forEach(pack => {
const { id, key, isInstall, isRemove } = pack || {};
if (!id || !key || (!isInstall && !isRemove)) {
window.log.warn(
'Received malformed sticker pack operation sync message'
);
return;
}
const status = window.Signal.Stickers.getStickerPackStatus(id);
if (status === 'installed' && isRemove) {
window.reduxActions.stickers.uninstallStickerPack(id, key, {
fromSync: true,
});
} else if (isInstall) {
if (status === 'advertised') {
window.reduxActions.stickers.installStickerPack(id, key, {
fromSync: true,
});
} else {
window.Signal.Stickers.downloadStickerPack(id, key, {
finalStatus: 'installed',
fromSync: true,
});
}
}
});
ev.confirm();
}
async function onContactReceived(ev) {
const details = ev.contactDetails;

View file

@ -12,6 +12,10 @@
const HOUR = MINUTE * 60;
function register(id, message) {
if (!id || !message) {
return message;
}
const existing = messageLookup[id];
if (existing) {
messageLookup[id] = {

View file

@ -35,12 +35,14 @@
PhoneNumber,
} = window.Signal.Types;
const {
upgradeMessageSchema,
loadAttachmentData,
getAbsoluteAttachmentPath,
writeNewAttachmentData,
deleteAttachmentData,
getAbsoluteAttachmentPath,
loadAttachmentData,
readStickerData,
upgradeMessageSchema,
writeNewAttachmentData,
} = window.Signal.Migrations;
const { addStickerPackReference } = window.Signal.Data;
const COLORS = [
'red',
@ -761,7 +763,7 @@
return _.without(this.get('members'), me);
},
async getQuoteAttachment(attachments, preview) {
async getQuoteAttachment(attachments, preview, sticker) {
if (attachments && attachments.length) {
return Promise.all(
attachments
@ -817,6 +819,23 @@
);
}
if (sticker && sticker.data && sticker.data.path) {
const { path, contentType } = sticker.data;
return [
{
contentType,
// Our protos library complains about this field being undefined, so we
// force it to null
fileName: null,
thumbnail: {
...(await loadAttachmentData(sticker.data)),
objectUrl: getAbsoluteAttachmentPath(path),
},
},
];
}
return [];
},
@ -825,6 +844,7 @@
const contact = quotedMessage.getContact();
const attachments = quotedMessage.get('attachments');
const preview = quotedMessage.get('preview');
const sticker = quotedMessage.get('sticker');
const body = quotedMessage.get('body');
const embeddedContact = quotedMessage.get('contact');
@ -837,11 +857,46 @@
author: contact.id,
id: quotedMessage.get('sent_at'),
text: body || embeddedContactName,
attachments: await this.getQuoteAttachment(attachments, preview),
attachments: await this.getQuoteAttachment(
attachments,
preview,
sticker
),
};
},
sendMessage(body, attachments, quote, preview) {
async sendStickerMessage(packId, stickerId) {
const packData = window.Signal.Stickers.getStickerPack(packId);
const stickerData = window.Signal.Stickers.getSticker(packId, stickerId);
if (!stickerData || !packData) {
window.log.warn(
`Attempted to send nonexistent (${packId}, ${stickerId}) sticker!`
);
return;
}
const { key } = packData;
const { path, width, height } = stickerData;
const arrayBuffer = await readStickerData(path);
const sticker = {
packId,
stickerId,
packKey: key,
data: {
size: arrayBuffer.byteLength,
data: arrayBuffer,
contentType: 'image/webp',
width,
height,
},
};
this.sendMessage(null, [], null, [], sticker);
window.reduxActions.stickers.useSticker(packId, stickerId);
},
sendMessage(body, attachments, quote, preview, sticker) {
this.clearTypingTimers();
const destination = this.id;
@ -863,6 +918,7 @@
now
);
// Here we move attachments to disk
const messageWithSchema = await upgradeMessageSchema({
type: 'outgoing',
body,
@ -874,6 +930,7 @@
received_at: now,
expireTimer,
recipients,
sticker,
});
if (this.isPrivate()) {
@ -885,6 +942,9 @@
};
const model = this.addSingleMessage(attributes);
if (sticker) {
await addStickerPackReference(model.id, sticker.packId);
}
const message = MessageController.register(model.id, model);
await window.Signal.Data.saveMessage(message.attributes, {
forceSave: true,
@ -935,6 +995,7 @@
finalAttachments,
quote,
preview,
sticker,
now,
expireTimer,
profileKey
@ -955,6 +1016,7 @@
finalAttachments,
quote,
preview,
sticker,
now,
expireTimer,
profileKey,
@ -968,6 +1030,7 @@
finalAttachments,
quote,
preview,
sticker,
now,
expireTimer,
profileKey,
@ -1271,6 +1334,7 @@
[],
null,
[],
null,
message.get('sent_at'),
expireTimer,
profileKey,

View file

@ -27,8 +27,16 @@
loadAttachmentData,
loadQuoteData,
loadPreviewData,
loadStickerData,
upgradeMessageSchema,
} = window.Signal.Migrations;
const {
copyStickerToAttachments,
deletePackReference,
downloadStickerPack,
getStickerPackStatus,
} = window.Signal.Stickers;
const { addStickerPackReference } = window.Signal.Data;
const { bytesFromString } = window.Signal.Crypto;
window.AccountCache = Object.create(null);
@ -389,6 +397,29 @@
// It doesn't need anything right now!
return {};
},
getAttachmentsForMessage() {
const sticker = this.get('sticker');
if (sticker && sticker.data) {
const { data } = sticker;
// We don't show anything if we're still loading a sticker
if (data.pending || !data.path) {
return [];
}
return [
{
...data,
url: getAbsoluteAttachmentPath(data.path),
},
];
}
const attachments = this.get('attachments') || [];
return attachments
.filter(attachment => !attachment.error)
.map(attachment => this.getPropsForAttachment(attachment));
},
getPropsForMessage() {
const phoneNumber = this.getSource();
const contact = this.findAndFormatContact(phoneNumber);
@ -408,12 +439,13 @@
const conversation = this.getConversation();
const isGroup = conversation && !conversation.isPrivate();
const attachments = this.get('attachments') || [];
const sticker = this.get('sticker');
return {
text: this.createNonBreakingLastSeparator(this.get('body')),
textPending: this.get('bodyPending'),
id: this.id,
isSticker: Boolean(sticker),
direction: this.isIncoming() ? 'incoming' : 'outgoing',
timestamp: this.get('sent_at'),
status: this.getMessagePropStatus(),
@ -423,9 +455,7 @@
authorProfileName: contact.profileName,
authorPhoneNumber: contact.phoneNumber,
conversationType: isGroup ? 'group' : 'direct',
attachments: attachments
.filter(attachment => !attachment.error)
.map(attachment => this.getPropsForAttachment(attachment)),
attachments: this.getAttachmentsForMessage(),
previews: this.getPropsForPreview(),
quote: this.getPropsForQuote(),
authorAvatarPath,
@ -584,6 +614,7 @@
return previews.map(preview => ({
...preview,
isStickerPack: window.Signal.LinkPreviews.isStickerPack(preview.url),
domain: window.Signal.LinkPreviews.getDomain(preview.url),
image: preview.image ? this.getPropsForAttachment(preview.image) : null,
}));
@ -708,6 +739,9 @@
if (this.get('attachments').length > 0) {
return i18n('mediaMessage');
}
if (this.get('sticker')) {
return i18n('message--getNotificationText--stickers');
}
if (this.isExpirationTimerUpdate()) {
const { expireTimer } = this.get('expirationTimerUpdate');
if (!expireTimer) {
@ -775,6 +809,16 @@
MessageController.unregister(this.id);
this.unload();
await deleteExternalMessageFiles(this.attributes);
const sticker = this.get('sticker');
if (!sticker) {
return;
}
const { packId } = sticker;
if (packId) {
await deletePackReference(this.id, packId);
}
},
unload() {
if (this.quotedMessage) {
@ -968,6 +1012,7 @@
const quoteWithData = await loadQuoteData(this.get('quote'));
const previewWithData = await loadPreviewData(this.get('preview'));
const stickerWithData = await loadStickerData(this.get('sticker'));
// Special-case the self-send case - we send only a sync message
if (recipients.length === 1 && recipients[0] === this.OUR_NUMBER) {
@ -978,6 +1023,7 @@
attachments,
quoteWithData,
previewWithData,
stickerWithData,
this.get('sent_at'),
this.get('expireTimer'),
profileKey
@ -996,6 +1042,7 @@
attachments,
quoteWithData,
previewWithData,
stickerWithData,
this.get('sent_at'),
this.get('expireTimer'),
profileKey,
@ -1013,6 +1060,7 @@
attachments,
quote: quoteWithData,
preview: previewWithData,
sticker: stickerWithData,
needsSync: !this.get('synced'),
expireTimer: this.get('expireTimer'),
profileKey,
@ -1058,6 +1106,7 @@
const quoteWithData = await loadQuoteData(this.get('quote'));
const previewWithData = await loadPreviewData(this.get('preview'));
const stickerWithData = await loadStickerData(this.get('sticker'));
// Special-case the self-send case - we send only a sync message
if (number === this.OUR_NUMBER) {
@ -1067,6 +1116,7 @@
attachments,
quoteWithData,
previewWithData,
stickerWithData,
this.get('sent_at'),
this.get('expireTimer'),
profileKey
@ -1083,6 +1133,7 @@
attachments,
quoteWithData,
previewWithData,
stickerWithData,
this.get('sent_at'),
this.get('expireTimer'),
profileKey,
@ -1405,8 +1456,62 @@
};
}
let sticker = this.get('sticker');
if (sticker) {
count += 1;
const { packId, stickerId, packKey } = sticker;
const status = getStickerPackStatus(packId);
let data;
if (status && status !== 'pending' && status !== 'error') {
try {
const copiedSticker = await copyStickerToAttachments(
packId,
stickerId
);
data = {
...copiedSticker,
contentType: 'image/webp',
};
} catch (error) {
window.log.error(
`Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
error && error.stack ? error.stack : error
);
}
}
if (!data) {
data = await window.Signal.AttachmentDownloads.addJob(sticker.data, {
messageId,
type: 'sticker',
index: 0,
});
}
if (!status) {
// kick off the download without waiting
downloadStickerPack(packId, packKey, { messageId });
} else {
await addStickerPackReference(messageId, packId);
}
sticker = {
...sticker,
packId,
data,
};
}
if (count > 0) {
this.set({ bodyPending, attachments, preview, contact, quote, group });
this.set({
bodyPending,
attachments,
preview,
contact,
quote,
group,
sticker,
});
await window.Signal.Data.saveMessage(this.attributes, {
Message: Whisper.Message,
@ -1481,7 +1586,6 @@
}
const queryAttachments = queryMessage.get('attachments') || [];
if (queryAttachments.length > 0) {
const queryFirst = queryAttachments[0];
const { thumbnail } = queryFirst;
@ -1507,6 +1611,14 @@
}
}
const sticker = queryMessage.get('sticker');
if (sticker && sticker.data && sticker.data.path) {
firstAttachment.thumbnail = {
...sticker.data,
copied: true,
};
}
return message;
},
@ -1617,9 +1729,10 @@
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
quote: dataMessage.quote,
preview,
quote: dataMessage.quote,
schemaVersion: dataMessage.schemaVersion,
sticker: dataMessage.sticker,
});
if (type === 'outgoing') {
const receipts = Whisper.DeliveryReceipts.forMessage(
@ -1841,7 +1954,7 @@
Whisper.Message.LONG_MESSAGE_CONTENT_TYPE = 'text/x-signal-plain';
Whisper.Message.getLongMessageAttachment = ({ body, attachments, now }) => {
if (body.length <= 2048) {
if (!body || body.length <= 2048) {
return {
body,
attachments,

View file

@ -1,6 +1,14 @@
/* global Whisper, Signal, setTimeout, clearTimeout, MessageController */
/* global
ConversationController,
Whisper,
Signal,
setTimeout,
clearTimeout,
MessageController
*/
const { isFunction, isNumber, omit } = require('lodash');
const { computeHash } = require('./types/conversation');
const getGuid = require('uuid/v4');
const {
getMessageById,
@ -356,17 +364,41 @@ async function _addAttachmentToMessage(message, attachment, { type, index }) {
}
if (type === 'group-avatar') {
const group = message.get('group');
if (!group) {
throw new Error("_addAttachmentToMessage: group didn't exist");
const conversationId = message.get('conversationid');
const conversation = ConversationController.get(conversationId);
if (!conversation) {
logger.warn("_addAttachmentToMessage: conversation didn't exist");
}
const existingAvatar = group.avatar;
const existingAvatar = conversation.get('avatar');
if (existingAvatar && existingAvatar.path) {
await Signal.Migrations.deleteAttachmentData(existingAvatar.path);
}
_replaceAttachment(group, 'avatar', attachment, logPrefix);
const data = await Signal.Migrations.loadAttachmentData(attachment.path);
conversation.set({
avatar: {
...attachment,
hash: await computeHash(data),
},
});
await Signal.Data.updateConversation(
conversationId,
conversation.attributes,
{
Conversation: Whisper.Conversation,
}
);
return;
}
if (type === 'sticker') {
const sticker = message.get('sticker');
if (!sticker) {
throw new Error("_addAttachmentToMessage: sticker didn't exist");
}
_replaceAttachment(sticker, 'data', attachment, logPrefix);
return;
}

View file

@ -7,6 +7,7 @@ module.exports = {
arrayBufferToBase64,
typedArrayToArrayBuffer,
base64ToArrayBuffer,
bytesFromHexString,
bytesFromString,
concatenateBytes,
constantTimeEqual,
@ -16,6 +17,7 @@ module.exports = {
decryptFile,
decryptSymmetric,
deriveAccessKey,
deriveStickerPackKey,
encryptAesCtr,
encryptDeviceName,
encryptAttachment,
@ -25,8 +27,10 @@ module.exports = {
getAccessKeyVerifier,
getFirstBytes,
getRandomBytes,
getRandomValue,
getViewOfArrayBuffer,
getZeroes,
hexFromBytes,
highBitsToInt,
hmacSha256,
intsToByteHighAndLow,
@ -58,6 +62,25 @@ function bytesFromString(string) {
function stringFromBytes(buffer) {
return dcodeIO.ByteBuffer.wrap(buffer).toString('utf8');
}
function hexFromBytes(buffer) {
return dcodeIO.ByteBuffer.wrap(buffer).toString('hex');
}
function bytesFromHexString(string) {
return dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();
}
async function deriveStickerPackKey(packKey) {
const salt = getZeroes(32);
const info = bytesFromString('Sticker Pack');
const [part1, part2] = await libsignal.HKDF.deriveSecrets(
packKey,
salt,
info
);
return concatenateBytes(part1, part2);
}
// High-level Operations
@ -366,6 +389,16 @@ function getRandomBytes(n) {
return bytes;
}
function getRandomValue(low, high) {
const diff = high - low;
const bytes = new Uint32Array(1);
window.crypto.getRandomValues(bytes);
// Because high and low are inclusive
const mod = diff + 1;
return bytes[0] % mod + low;
}
function getZeroes(n) {
const result = new Uint8Array(n);

18
js/modules/data.d.ts vendored
View file

@ -1,2 +1,20 @@
export function searchMessages(query: string): Promise<Array<any>>;
export function searchConversations(query: string): Promise<Array<any>>;
export function updateStickerLastUsed(
packId: string,
stickerId: number,
time: number
): Promise<void>;
export function updateStickerPackStatus(
packId: string,
status: 'advertised' | 'installed' | 'error' | 'pending',
options?: { timestamp: number }
): Promise<void>;
export function getRecentStickers(): Promise<
Array<{
id: number;
packId: string;
}>
>;

View file

@ -27,6 +27,7 @@ const DATABASE_UPDATE_TIMEOUT = 2 * 60 * 1000; // two minutes
const SQL_CHANNEL_KEY = 'sql-channel';
const ERASE_SQL_KEY = 'erase-sql-key';
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
const ERASE_STICKERS_KEY = 'erase-stickers';
const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
const _jobs = Object.create(null);
@ -138,6 +139,17 @@ module.exports = {
removeAttachmentDownloadJob,
removeAllAttachmentDownloadJobs,
createOrUpdateStickerPack,
updateStickerPackStatus,
createOrUpdateSticker,
updateStickerLastUsed,
addStickerPackReference,
deleteStickerPackReference,
deleteStickerPack,
getAllStickerPacks,
getAllStickers,
getRecentStickers,
removeAll,
removeAllConfiguration,
@ -884,6 +896,44 @@ async function removeAllAttachmentDownloadJobs() {
await channels.removeAllAttachmentDownloadJobs();
}
// Stickers
async function createOrUpdateStickerPack(pack) {
await channels.createOrUpdateStickerPack(pack);
}
async function updateStickerPackStatus(packId, status, options) {
await channels.updateStickerPackStatus(packId, status, options);
}
async function createOrUpdateSticker(sticker) {
await channels.createOrUpdateSticker(sticker);
}
async function updateStickerLastUsed(packId, stickerId, timestamp) {
await channels.updateStickerLastUsed(packId, stickerId, timestamp);
}
async function addStickerPackReference(messageId, packId) {
await channels.addStickerPackReference(messageId, packId);
}
async function deleteStickerPackReference(messageId, packId) {
const paths = await channels.deleteStickerPackReference(messageId, packId);
return paths;
}
async function deleteStickerPack(packId) {
const paths = await channels.deleteStickerPack(packId);
return paths;
}
async function getAllStickerPacks() {
const packs = await channels.getAllStickerPacks();
return packs;
}
async function getAllStickers() {
const stickers = await channels.getAllStickers();
return stickers;
}
async function getRecentStickers() {
const recentStickers = await channels.getRecentStickers();
return recentStickers;
}
// Other
async function removeAll() {
@ -903,6 +953,7 @@ async function removeOtherData() {
await Promise.all([
callChannel(ERASE_SQL_KEY),
callChannel(ERASE_ATTACHMENTS_KEY),
callChannel(ERASE_STICKERS_KEY),
]);
}

View file

@ -18,6 +18,7 @@ module.exports = {
isLinkInWhitelist,
isMediaLinkInWhitelist,
isLinkSneaky,
isStickerPack,
};
const SUPPORTED_DOMAINS = [
@ -37,7 +38,9 @@ const SUPPORTED_DOMAINS = [
'pinterest.com',
'www.pinterest.com',
'pin.it',
'signal.org',
];
function isLinkInWhitelist(link) {
try {
const url = new URL(link);
@ -61,6 +64,10 @@ function isLinkInWhitelist(link) {
}
}
function isStickerPack(link) {
return (link || '').startsWith('https://signal.org/addstickers/');
}
const SUPPORTED_MEDIA_DOMAINS = /^([^.]+\.)*(ytimg.com|cdninstagram.com|redd.it|imgur.com|fbcdn.net|pinimg.com)$/i;
function isMediaLinkInWhitelist(link) {
try {
@ -138,28 +145,33 @@ function getDomain(url) {
const MB = 1024 * 1024;
const KB = 1024;
function getChunkPattern(size) {
function getChunkPattern(size, initialOffset) {
if (size > MB) {
return _getRequestPattern(size, MB);
return _getRequestPattern(size, MB, initialOffset);
} else if (size > 500 * KB) {
return _getRequestPattern(size, 500 * KB);
return _getRequestPattern(size, 500 * KB, initialOffset);
} else if (size > 100 * KB) {
return _getRequestPattern(size, 100 * KB);
return _getRequestPattern(size, 100 * KB, initialOffset);
} else if (size > 50 * KB) {
return _getRequestPattern(size, 50 * KB);
return _getRequestPattern(size, 50 * KB, initialOffset);
} else if (size > 10 * KB) {
return _getRequestPattern(size, 10 * KB);
return _getRequestPattern(size, 10 * KB, initialOffset);
} else if (size > KB) {
return _getRequestPattern(size, KB);
return _getRequestPattern(size, KB, initialOffset);
}
throw new Error(`getChunkPattern: Unsupported size: ${size}`);
return {
start: {
start: initialOffset,
end: size - 1,
},
};
}
function _getRequestPattern(size, increment) {
function _getRequestPattern(size, increment, initialOffset) {
const results = [];
let offset = 0;
let offset = initialOffset || 0;
while (size - offset > increment) {
results.push({
start: offset,

View file

@ -9,6 +9,7 @@ const Emoji = require('../../ts/util/emoji');
const IndexedDB = require('./indexeddb');
const Notifications = require('../../ts/notifications');
const OS = require('../../ts/OS');
const Stickers = require('./stickers');
const Settings = require('./settings');
const Util = require('../../ts/util');
const { migrateToSQL } = require('./migrate_to_sql');
@ -69,8 +70,20 @@ const {
// State
const { createLeftPane } = require('../../ts/state/roots/createLeftPane');
const {
createStickerButton,
} = require('../../ts/state/roots/createStickerButton');
const {
createStickerManager,
} = require('../../ts/state/roots/createStickerManager');
const {
createStickerPreviewModal,
} = require('../../ts/state/roots/createStickerPreviewModal');
const { createStore } = require('../../ts/state/createStore');
const conversationsDuck = require('../../ts/state/ducks/conversations');
const itemsDuck = require('../../ts/state/ducks/items');
const stickersDuck = require('../../ts/state/ducks/stickers');
const userDuck = require('../../ts/state/ducks/user');
// Migrations
@ -112,6 +125,7 @@ function initializeMigrations({
}
const {
getPath,
getStickersPath,
createReader,
createAbsolutePathGetter,
createWriterForNew,
@ -130,25 +144,40 @@ function initializeMigrations({
const loadAttachmentData = Type.loadData(readAttachmentData);
const loadPreviewData = MessageType.loadPreviewData(loadAttachmentData);
const loadQuoteData = MessageType.loadQuoteData(loadAttachmentData);
const loadStickerData = MessageType.loadStickerData(loadAttachmentData);
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
const writeNewAttachmentData = createWriterForNew(attachmentsPath);
const copyIntoAttachmentsDirectory = Attachments.copyIntoAttachmentsDirectory(
attachmentsPath
);
const stickersPath = getStickersPath(userDataPath);
const writeNewStickerData = createWriterForNew(stickersPath);
const getAbsoluteStickerPath = createAbsolutePathGetter(stickersPath);
const deleteSticker = Attachments.createDeleter(stickersPath);
const readStickerData = createReader(stickersPath);
return {
attachmentsPath,
copyIntoAttachmentsDirectory,
deleteAttachmentData: deleteOnDisk,
deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({
deleteAttachmentData: Type.deleteData(deleteOnDisk),
deleteOnDisk,
}),
deleteSticker,
getAbsoluteAttachmentPath,
getAbsoluteStickerPath,
getPlaceholderMigrations,
getCurrentVersion,
loadAttachmentData,
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
loadPreviewData,
loadQuoteData,
loadStickerData,
readAttachmentData,
readStickerData,
run,
processNewAttachment: attachment =>
MessageType.processNewAttachment(attachment, {
@ -161,6 +190,13 @@ function initializeMigrations({
makeVideoScreenshot,
logger,
}),
processNewSticker: stickerData =>
MessageType.processNewSticker(stickerData, {
writeNewStickerData,
getAbsoluteStickerPath,
getImageDimensions,
logger,
}),
upgradeMessageSchema: (message, options = {}) => {
const { maxVersion } = options;
@ -227,10 +263,15 @@ exports.setup = (options = {}) => {
const Roots = {
createLeftPane,
createStickerButton,
createStickerManager,
createStickerPreviewModal,
};
const Ducks = {
conversations: conversationsDuck,
items: itemsDuck,
user: userDuck,
stickers: stickersDuck,
};
const State = {
bindActionCreators,
@ -278,6 +319,7 @@ exports.setup = (options = {}) => {
RefreshSenderCertificate,
Settings,
State,
Stickers,
Types,
Util,
Views,

1
js/modules/stickers.d.ts vendored Normal file
View file

@ -0,0 +1 @@
export function maybeDeletePack(packId: string): Promise<void>;

495
js/modules/stickers.js Normal file
View file

@ -0,0 +1,495 @@
/* global
textsecure,
Signal,
log,
reduxStore,
reduxActions,
URL
*/
const BLESSED_PACKS = {};
const { isNumber, pick, reject, groupBy } = require('lodash');
const pMap = require('p-map');
const Queue = require('p-queue');
const qs = require('qs');
const { makeLookup } = require('../../ts/util/makeLookup');
const { base64ToArrayBuffer, deriveStickerPackKey } = require('./crypto');
const {
addStickerPackReference,
createOrUpdateSticker,
createOrUpdateStickerPack,
deleteStickerPack,
deleteStickerPackReference,
getAllStickerPacks,
getAllStickers,
getRecentStickers,
updateStickerPackStatus,
} = require('./data');
module.exports = {
BLESSED_PACKS,
copyStickerToAttachments,
deletePack,
deletePackReference,
downloadStickerPack,
getDataFromLink,
getInitialState,
getInstalledStickerPacks,
getSticker,
getStickerPack,
getStickerPackStatus,
load,
maybeDeletePack,
downloadQueuedPacks,
redactPackId,
};
let initialState = null;
let packsToDownload = null;
const downloadQueue = new Queue({ concurrency: 1 });
async function load() {
const [packs, recentStickers] = await Promise.all([
getPacksForRedux(),
getRecentStickersForRedux(),
]);
initialState = {
packs,
recentStickers,
blessedPacks: BLESSED_PACKS,
};
packsToDownload = capturePacksToDownload(packs);
}
function getDataFromLink(link) {
const { hash } = new URL(link);
if (!hash) {
return null;
}
const data = hash.slice(1);
const params = qs.parse(data);
return {
id: params.pack_id,
key: params.pack_key,
};
}
function getInstalledStickerPacks() {
const state = reduxStore.getState();
const { stickers } = state;
const { packs } = stickers;
if (!packs) {
return [];
}
const values = Object.values(packs);
return values.filter(pack => pack.status === 'installed');
}
function downloadQueuedPacks() {
const ids = Object.keys(packsToDownload);
ids.forEach(id => {
const { key, status } = packsToDownload[id];
// The queuing is done inside this function, no need to await here
downloadStickerPack(id, key, { finalStatus: status });
});
packsToDownload = {};
}
function capturePacksToDownload(existingPackLookup) {
const toDownload = Object.create(null);
// First, ensure that blessed packs are in good shape
const blessedIds = Object.keys(BLESSED_PACKS);
blessedIds.forEach(id => {
const existing = existingPackLookup[id];
if (
!existing ||
(existing.status !== 'advertised' && existing.status !== 'installed')
) {
toDownload[id] = {
id,
...BLESSED_PACKS[id],
};
}
});
// Then, find error cases in packs we already know about
const existingIds = Object.keys(existingPackLookup);
existingIds.forEach(id => {
if (toDownload[id]) {
return;
}
const existing = existingPackLookup[id];
if (doesPackNeedDownload(existing)) {
toDownload[id] = {
id,
key: existing.key,
status: existing.attemptedStatus,
};
}
});
return toDownload;
}
function doesPackNeedDownload(pack) {
if (!pack) {
return true;
}
const stickerCount = Object.keys(pack.stickers || {}).length;
return (
!pack.status ||
pack.status === 'error' ||
pack.status === 'pending' ||
!pack.stickerCount ||
stickerCount < pack.stickerCount
);
}
async function getPacksForRedux() {
const [packs, stickers] = await Promise.all([
getAllStickerPacks(),
getAllStickers(),
]);
const stickersByPack = groupBy(stickers, sticker => sticker.packId);
const fullSet = packs.map(pack => ({
...pack,
stickers: makeLookup(stickersByPack[pack.id] || [], 'id'),
}));
return makeLookup(fullSet, 'id');
}
async function getRecentStickersForRedux() {
const recent = await getRecentStickers();
return recent.map(sticker => ({
packId: sticker.packId,
stickerId: sticker.id,
}));
}
function getInitialState() {
return initialState;
}
function redactPackId(packId) {
return `[REDACTED]${packId.slice(-3)}`;
}
function getReduxStickerActions() {
const actions = reduxActions;
if (actions && actions.stickers) {
return actions.stickers;
}
return {};
}
async function decryptSticker(packKey, ciphertext) {
const binaryKey = base64ToArrayBuffer(packKey);
const derivedKey = await deriveStickerPackKey(binaryKey);
const plaintext = await textsecure.crypto.decryptAttachment(
ciphertext,
derivedKey
);
return plaintext;
}
async function downloadSticker(packId, packKey, proto) {
const ciphertext = await textsecure.messaging.getSticker(packId, proto.id);
const plaintext = await decryptSticker(packKey, ciphertext);
const sticker = await Signal.Migrations.processNewSticker(plaintext);
return {
...pick(proto, ['id', 'emoji']),
...sticker,
packId,
};
}
async function downloadStickerPack(packId, packKey, options = {}) {
// This will ensure that only one download process is in progress at any given time
return downloadQueue.add(async () => {
try {
await doDownloadStickerPack(packId, packKey, options);
} catch (error) {
log.error(
'doDownloadStickerPack threw an error:',
error && error.stack ? error.stack : error
);
}
});
}
async function doDownloadStickerPack(packId, packKey, options = {}) {
const { messageId, fromSync } = options;
const {
stickerAdded,
stickerPackAdded,
stickerPackUpdated,
installStickerPack,
} = getReduxStickerActions();
const finalStatus = options.finalStatus || 'advertised';
const existing = getStickerPack(packId);
if (!doesPackNeedDownload(existing)) {
log.warn(
`Download for pack ${redactPackId(
packId
)} requested, but it does not need re-download. Skipping.`
);
return;
}
const downloadAttempts = (existing ? existing.downloadAttempts || 0 : 0) + 1;
if (downloadAttempts > 3) {
log.warn(
`Refusing to attempt another download for pack ${redactPackId(
packId
)}, attempt number ${downloadAttempts}`
);
if (existing.status !== 'error') {
await updateStickerPackStatus(packId, 'error');
stickerPackUpdated(packId, {
status: 'error',
});
}
return;
}
let coverProto;
let coverStickerId;
let coverIncludedInList;
let nonCoverStickers;
try {
const ciphertext = await textsecure.messaging.getStickerPackManifest(
packId
);
const plaintext = await decryptSticker(packKey, ciphertext);
const proto = textsecure.protobuf.StickerPack.decode(plaintext);
const firstStickerProto = proto.stickers ? proto.stickers[0] : null;
const stickerCount = proto.stickers.length;
coverProto = proto.cover || firstStickerProto;
coverStickerId = coverProto ? coverProto.id : null;
if (!coverProto || !isNumber(coverStickerId)) {
throw new Error(
`Sticker pack ${redactPackId(
packId
)} is malformed - it has no cover, and no stickers`
);
}
nonCoverStickers = reject(
proto.stickers,
sticker => !isNumber(sticker.id) || sticker.id === coverStickerId
);
coverIncludedInList = nonCoverStickers.length < stickerCount;
// status can be:
// - 'pending'
// - 'advertised'
// - 'error'
// - 'installed'
const pack = {
id: packId,
key: packKey,
attemptedStatus: finalStatus,
coverStickerId,
downloadAttempts,
stickerCount,
status: 'pending',
...pick(proto, ['title', 'author']),
};
await createOrUpdateStickerPack(pack);
stickerPackAdded(pack);
if (messageId) {
await addStickerPackReference(messageId, packId);
}
} catch (error) {
log.error(
`Error downloading manifest for sticker pack ${redactPackId(packId)}:`,
error && error.stack ? error.stack : error
);
const pack = {
id: packId,
key: packKey,
attemptedStatus: finalStatus,
downloadAttempts,
status: 'error',
};
await createOrUpdateStickerPack(pack);
stickerPackAdded(pack);
return;
}
// We have a separate try/catch here because we're starting to download stickers here
// and we want to preserve more of the pack on an error.
try {
const downloadStickerJob = async stickerProto => {
const stickerInfo = await downloadSticker(packId, packKey, stickerProto);
const sticker = {
...stickerInfo,
isCoverOnly: !coverIncludedInList && stickerInfo.id === coverStickerId,
};
await createOrUpdateSticker(sticker);
stickerAdded(sticker);
};
// Download the cover first
await downloadStickerJob(coverProto);
// Then the rest
await pMap(nonCoverStickers, downloadStickerJob, { concurrency: 3 });
if (finalStatus === 'installed') {
await installStickerPack(packId, packKey, { fromSync });
} else {
// Mark the pack as complete
await updateStickerPackStatus(packId, finalStatus);
stickerPackUpdated(packId, {
status: finalStatus,
});
}
} catch (error) {
log.error(
`Error downloading stickers for sticker pack ${redactPackId(packId)}:`,
error && error.stack ? error.stack : error
);
const errorState = 'error';
await updateStickerPackStatus(packId, errorState);
if (stickerPackUpdated) {
stickerPackUpdated(packId, {
state: errorState,
});
}
}
}
function getStickerPack(packId) {
const state = reduxStore.getState();
const { stickers } = state;
const { packs } = stickers;
if (!packs) {
return null;
}
return packs[packId];
}
function getStickerPackStatus(packId) {
const pack = getStickerPack(packId);
if (!pack) {
return null;
}
return pack.status;
}
function getSticker(packId, stickerId) {
const state = reduxStore.getState();
const { stickers } = state;
const { packs } = stickers;
const pack = packs[packId];
if (!pack || !pack.stickers) {
return null;
}
return pack.stickers[stickerId];
}
async function copyStickerToAttachments(packId, stickerId) {
const sticker = getSticker(packId, stickerId);
if (!sticker) {
return null;
}
const { path } = sticker;
const absolutePath = Signal.Migrations.getAbsoluteStickerPath(path);
const newPath = await Signal.Migrations.copyIntoAttachmentsDirectory(
absolutePath
);
return {
...sticker,
path: newPath,
};
}
// In the case where a sticker pack is uninstalled, we want to delete it if there are no
// more references left. We'll delete a nonexistent reference, then check if there are
// any references left, just like usual.
async function maybeDeletePack(packId) {
// This hardcoded string is fine because message ids are GUIDs
await deletePackReference('NOT-USED', packId);
}
// We don't generally delete packs outright; we just remove references to them, and if
// the last reference is deleted, we finally then remove the pack itself from database
// and from disk.
async function deletePackReference(messageId, packId) {
const isBlessed = Boolean(BLESSED_PACKS[packId]);
if (isBlessed) {
return;
}
// This call uses locking to prevent race conditions with other reference removals,
// or an incoming message creating a new message->pack reference
const paths = await deleteStickerPackReference(messageId, packId);
// If we don't get a list of paths back, then the sticker pack was not deleted
if (!paths) {
return;
}
const { removeStickerPack } = getReduxStickerActions();
removeStickerPack(packId);
await pMap(paths, Signal.Migrations.deleteSticker, {
concurrency: 3,
});
}
// The override; doesn't honor our ref-counting scheme - just deletes it all.
async function deletePack(packId) {
const isBlessed = Boolean(BLESSED_PACKS[packId]);
if (isBlessed) {
return;
}
// This call uses locking to prevent race conditions with other reference removals,
// or an incoming message creating a new message->pack reference
const paths = await deleteStickerPack(packId);
const { removeStickerPack } = getReduxStickerActions();
removeStickerPack(packId);
await pMap(paths, Signal.Migrations.deleteSticker, {
concurrency: 3,
});
}

View file

@ -179,7 +179,7 @@ exports.loadData = readAttachmentData => {
}
const data = await readAttachmentData(attachment.path);
return Object.assign({}, attachment, { data });
return Object.assign({}, attachment, { data, size: data.byteLength });
};
};

View file

@ -140,11 +140,12 @@ async function deleteExternalFiles(conversation, options = {}) {
}
module.exports = {
deleteExternalFiles,
migrateConversation,
maybeUpdateAvatar,
maybeUpdateProfileAvatar,
createLastMessageUpdate,
arrayBufferToBase64,
base64ToArrayBuffer,
computeHash,
createLastMessageUpdate,
deleteExternalFiles,
maybeUpdateAvatar,
maybeUpdateProfileAvatar,
migrateConversation,
};

View file

@ -308,7 +308,33 @@ const toVersion9 = exports._withSchemaVersion({
});
const toVersion10 = exports._withSchemaVersion({
schemaVersion: 10,
upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem),
upgrade: async (message, context) => {
const processPreviews = exports._mapPreviewAttachments(
Attachment.migrateDataToFileSystem
);
const processSticker = async (stickerMessage, stickerContext) => {
const { sticker } = stickerMessage;
if (!sticker || !sticker.data || !sticker.data.data) {
return stickerMessage;
}
return {
...stickerMessage,
sticker: {
...sticker,
data: await Attachment.migrateDataToFileSystem(
sticker.data,
stickerContext
),
},
};
};
const previewProcessed = await processPreviews(message, context);
const stickerProcessed = await processSticker(previewProcessed, context);
return stickerProcessed;
},
});
const VERSIONS = [
@ -462,6 +488,44 @@ exports.processNewAttachment = async (
return finalAttachment;
};
exports.processNewSticker = async (
stickerData,
{
writeNewStickerData,
getAbsoluteStickerPath,
getImageDimensions,
logger,
} = {}
) => {
if (!isFunction(writeNewStickerData)) {
throw new TypeError('context.writeNewStickerData is required');
}
if (!isFunction(getAbsoluteStickerPath)) {
throw new TypeError('context.getAbsoluteStickerPath is required');
}
if (!isFunction(getImageDimensions)) {
throw new TypeError('context.getImageDimensions is required');
}
if (!isObject(logger)) {
throw new TypeError('context.logger is required');
}
const path = await writeNewStickerData(stickerData);
const absolutePath = await getAbsoluteStickerPath(path);
const { width, height } = await getImageDimensions({
objectUrl: absolutePath,
logger,
});
return {
contentType: 'image/webp',
path,
width,
height,
};
};
exports.createAttachmentLoader = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError(
@ -532,6 +596,23 @@ exports.loadPreviewData = loadAttachmentData => {
};
};
exports.loadStickerData = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError('loadStickerData: loadAttachmentData is required');
}
return async sticker => {
if (!sticker || !sticker.data) {
return null;
}
return {
...sticker,
data: await loadAttachmentData(sticker.data),
};
};
};
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
if (!isFunction(deleteAttachmentData)) {
throw new TypeError(
@ -546,7 +627,7 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
}
return async message => {
const { attachments, quote, contact, preview } = message;
const { attachments, quote, contact, preview, sticker } = message;
if (attachments && attachments.length) {
await Promise.all(attachments.map(deleteAttachmentData));
@ -590,6 +671,14 @@ exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
})
);
}
if (sticker && sticker.data && sticker.data.path) {
await deleteOnDisk(sticker.data.path);
if (sticker.data.thumbnail && sticker.data.thumbnail.path) {
await deleteOnDisk(sticker.data.thumbnail.path);
}
}
};
};

View file

@ -4,8 +4,9 @@ const ProxyAgent = require('proxy-agent');
const { Agent } = require('https');
const is = require('@sindresorhus/is');
const { redactPackId } = require('./stickers');
/* global Buffer, setTimeout, log, _, getGuid */
/* global Signal, Buffer, setTimeout, log, _, getGuid */
/* eslint-disable more/no-then, no-bitwise, no-nested-ternary */
@ -175,16 +176,12 @@ function getContentType(response) {
function _promiseAjax(providedUrl, options) {
return new Promise((resolve, reject) => {
const url = providedUrl || `${options.host}/${options.path}`;
if (options.disableLogs) {
log.info(
`${options.type} [REDACTED_URL]${
options.unauthenticated ? ' (unauth)' : ''
}`
);
const unauthLabel = options.unauthenticated ? ' (unauth)' : '';
if (options.redactUrl) {
log.info(`${options.type} ${options.redactUrl(url)}${unauthLabel}`);
} else {
log.info(
`${options.type} ${url}${options.unauthenticated ? ' (unauth)' : ''}`
);
log.info(`${options.type} ${url}${unauthLabel}`);
}
const timeout =
@ -282,10 +279,10 @@ function _promiseAjax(providedUrl, options) {
if (options.responseType === 'json') {
if (options.validateResponse) {
if (!_validateResponse(result, options.validateResponse)) {
if (options.disableLogs) {
if (options.redactUrl) {
log.info(
options.type,
'[REDACTED_URL]',
options.redactUrl(url),
response.status,
'Error'
);
@ -304,10 +301,10 @@ function _promiseAjax(providedUrl, options) {
}
}
if (response.status >= 0 && response.status < 400) {
if (options.disableLogs) {
if (options.redactUrl) {
log.info(
options.type,
'[REDACTED_URL]',
options.redactUrl(url),
response.status,
'Success'
);
@ -324,8 +321,13 @@ function _promiseAjax(providedUrl, options) {
return resolve(result, response.status);
}
if (options.disableLogs) {
log.info(options.type, '[REDACTED_URL]', response.status, 'Error');
if (options.redactUrl) {
log.info(
options.type,
options.redactUrl(url),
response.status,
'Error'
);
} else {
log.error(options.type, url, response.status, 'Error');
}
@ -340,8 +342,8 @@ function _promiseAjax(providedUrl, options) {
});
})
.catch(e => {
if (options.disableLogs) {
log.error(options.type, '[REDACTED_URL]', 0, 'Error');
if (options.redactUrl) {
log.error(options.type, options.redactUrl(url), 0, 'Error');
} else {
log.error(options.type, url, 0, 'Error');
}
@ -435,6 +437,7 @@ function initialize({
function connect({ username: initialUsername, password: initialPassword }) {
let username = initialUsername;
let password = initialPassword;
const PARSE_RANGE_HEADER = /\/(\d+)$/;
// Thanks, function hoisting!
return {
@ -449,8 +452,9 @@ function initialize({
getProfile,
getProfileUnauth,
getProvisioningSocket,
getProxiedSize,
getSenderCertificate,
getSticker,
getStickerPackManifest,
makeProxiedRequest,
putAttachment,
registerKeys,
@ -834,6 +838,33 @@ function initialize({
});
}
function redactStickerUrl(stickerUrl) {
return stickerUrl.replace(
/(\/stickers\/)([^/]+)(\/)/,
(match, begin, packId, end) => `${begin}${redactPackId(packId)}${end}`
);
}
function getSticker(packId, stickerId) {
return _outerAjax(`${cdnUrl}/stickers/${packId}/full/${stickerId}`, {
certificateAuthority,
proxyUrl,
responseType: 'arraybuffer',
type: 'GET',
redactUrl: redactStickerUrl,
});
}
function getStickerPackManifest(packId) {
return _outerAjax(`${cdnUrl}/stickers/${packId}/manifest.proto`, {
certificateAuthority,
proxyUrl,
responseType: 'arraybuffer',
type: 'GET',
redactUrl: redactStickerUrl,
});
}
async function getAttachment(id) {
// This is going to the CDN, not the service, so we use _outerAjax
return _outerAjax(`${cdnUrl}/attachments/${id}`, {
@ -918,45 +949,64 @@ function initialize({
return attachmentIdString;
}
// eslint-disable-next-line no-shadow
async function getProxiedSize(url) {
const result = await _outerAjax(url, {
processData: false,
responseType: 'arraybufferwithdetails',
proxyUrl: contentProxyUrl,
type: 'HEAD',
disableLogs: true,
});
function getHeaderPadding() {
const length = Signal.Crypto.getRandomValue(1, 64);
let characters = '';
const { response } = result;
if (!response.headers || !response.headers.get) {
throw new Error('getProxiedSize: Problem retrieving header value');
for (let i = 0, max = length; i < max; i += 1) {
characters += String.fromCharCode(
Signal.Crypto.getRandomValue(65, 122)
);
}
const size = response.headers.get('content-length');
return parseInt(size, 10);
return characters;
}
// eslint-disable-next-line no-shadow
function makeProxiedRequest(url, options = {}) {
async function makeProxiedRequest(url, options = {}) {
const { returnArrayBuffer, start, end } = options;
let headers;
const headers = {
'X-SignalPadding': getHeaderPadding(),
};
if (_.isNumber(start) && _.isNumber(end)) {
headers = {
Range: `bytes=${start}-${end}`,
};
headers.Range = `bytes=${start}-${end}`;
}
return _outerAjax(url, {
const result = await _outerAjax(url, {
processData: false,
responseType: returnArrayBuffer ? 'arraybufferwithdetails' : null,
proxyUrl: contentProxyUrl,
type: 'GET',
redirect: 'follow',
disableLogs: true,
redactUrl: () => '[REDACTED_URL]',
headers,
});
if (!returnArrayBuffer) {
return result;
}
const { response } = result;
if (!response.headers || !response.headers.get) {
throw new Error('makeProxiedRequest: Problem retrieving header value');
}
const range = response.headers.get('content-range');
const match = PARSE_RANGE_HEADER.exec(range);
if (!match || !match[1]) {
throw new Error(
`makeProxiedRequest: Unable to parse total size from ${range}`
);
}
const totalSize = parseInt(match[1], 10);
return {
totalSize,
result,
};
}
function getMessageSocket() {

View file

@ -1,3 +1,4 @@
/* global _ */
/* eslint-disable more/no-then */
// eslint-disable-next-line func-names
@ -24,6 +25,10 @@
items[key] = data;
await window.Signal.Data.createOrUpdateItem(data);
if (_.has(window, ['reduxActions', 'items', 'putItemExternal'])) {
window.reduxActions.items.putItemExternal(key, value);
}
}
function get(key, defaultValue) {
@ -46,6 +51,10 @@
delete items[key];
await window.Signal.Data.removeItemById(key);
if (_.has(window, ['reduxActions', 'items', 'removeItemExternal'])) {
window.reduxActions.items.removeItemExternal(key);
}
}
function onready(callback) {
@ -77,6 +86,10 @@
callListeners();
}
function getItemsState() {
return _.clone(items);
}
function reset() {
ready = false;
items = Object.create(null);
@ -86,6 +99,7 @@
fetch,
put,
get,
getItemsState,
remove,
onready,
reset,

View file

@ -104,6 +104,9 @@
);
this.listenTo(this.model.messageCollection, 'force-send', this.forceSend);
this.listenTo(this.model.messageCollection, 'delete', this.deleteMessage);
this.listenTo(this.model.messageCollection, 'height-changed', () =>
this.view.scrollToBottomIfNeeded()
);
this.listenTo(
this.model.messageCollection,
'scroll-to-message',
@ -276,15 +279,18 @@
this.$('.send-message').blur(this.unfocusBottomBar.bind(this));
this.$emojiPanelContainer = this.$('.emoji-panel-container');
this.setupStickerPickerButton();
},
events: {
keydown: 'onKeyDown',
'submit .send': 'checkUnverifiedSendMessage',
'submit .send': 'clickSend',
'input .send-message': 'updateMessageFieldSize',
'keydown .send-message': 'updateMessageFieldSize',
'keyup .send-message': 'onKeyUp',
click: 'onClick',
'click .sticker-button-placeholder': 'onClickStickerButtonPlaceholder',
'click .bottom-bar': 'focusMessageField',
'click .capture-audio .microphone': 'captureAudio',
'click .module-scroll-down': 'scrollToBottom',
@ -308,6 +314,28 @@
paste: 'onPaste',
},
setupStickerPickerButton() {
const props = {
onClickAddPack: () => this.showStickerManager(),
onPickSticker: (packId, stickerId) =>
this.sendStickerMessage({ packId, stickerId }),
};
this.stickerButtonView = new Whisper.ReactWrapperView({
className: 'sticker-button-wrapper',
JSX: Signal.State.Roots.createStickerButton(window.reduxStore, props),
});
// Finally, add it to the DOM
this.$('.sticker-button-placeholder').append(this.stickerButtonView.el);
},
// We need this, or clicking the sticker button will submit the form and send any
// mid-composition message content.
onClickStickerButtonPlaceholder(e) {
e.preventDefault();
},
onChooseAttachment(e) {
if (e) {
e.stopPropagation();
@ -366,6 +394,13 @@
this.fileInput.remove();
this.titleView.remove();
if (this.stickerButtonView) {
this.stickerButtonView.remove();
}
if (this.stickerPreviewModalView) {
this.stickerPreviewModalView.remove();
}
if (this.captureAudioView) {
this.captureAudioView.remove();
@ -1282,6 +1317,26 @@
dialog.focusCancel();
},
showStickerPackPreview(packId) {
const props = {
packId,
onClose: () => {
this.stickerPreviewModalView.remove();
},
};
this.stickerPreviewModalView = new Whisper.ReactWrapperView({
className: 'sticker-preview-modal-wrapper',
JSX: Signal.State.Roots.createStickerPreviewModal(
window.reduxStore,
props
),
onClose: () => {
this.stickerPreviewModalView = null;
},
});
},
showLightbox({ attachment, messageId }) {
const message = this.model.messageCollection.get(messageId);
if (!message) {
@ -1289,6 +1344,13 @@
`showLightbox: did not find message for id ${messageId}`
);
}
const sticker = message.get('sticker');
if (sticker) {
const { packId } = sticker;
this.showStickerPackPreview(packId);
return;
}
const { contentType, path } = attachment;
if (
@ -1400,6 +1462,21 @@
view.render();
},
showStickerManager() {
const view = new Whisper.ReactWrapperView({
className: ['sticker-manager-wrapper', 'panel'].join(' '),
JSX: Signal.State.Roots.createStickerManager(window.reduxStore),
onClose: () => {
this.resetPanel();
this.updateHeader();
},
});
this.listenBack(view);
this.updateHeader();
view.render();
},
showContactDetail({ contact, signalAccount }) {
const view = new Whisper.ReactWrapperView({
Component: Signal.Components.ContactDetail,
@ -1449,6 +1526,8 @@
if (this.panels.length === 0) {
this.$el.trigger('force-resize');
// Make sure poppers are positioned properly
window.dispatchEvent(new Event('resize'));
}
},
@ -1482,99 +1561,121 @@
}
},
showSendConfirmationDialog(e, contacts) {
let message;
const isUnverified = this.model.isUnverified();
showSendAnywayDialog(contacts) {
return new Promise(resolve => {
let message;
const isUnverified = this.model.isUnverified();
if (contacts.length > 1) {
if (isUnverified) {
message = i18n('changedSinceVerifiedMultiple');
if (contacts.length > 1) {
if (isUnverified) {
message = i18n('changedSinceVerifiedMultiple');
} else {
message = i18n('changedRecentlyMultiple');
}
} else {
message = i18n('changedRecentlyMultiple');
const contactName = contacts.at(0).getTitle();
if (isUnverified) {
message = i18n('changedSinceVerified', [contactName, contactName]);
} else {
message = i18n('changedRecently', [contactName, contactName]);
}
}
} else {
const contactName = contacts.at(0).getTitle();
if (isUnverified) {
message = i18n('changedSinceVerified', [contactName, contactName]);
} else {
message = i18n('changedRecently', [contactName, contactName]);
}
}
const dialog = new Whisper.ConfirmationDialogView({
message,
okText: i18n('sendAnyway'),
resolve: () => {
this.checkUnverifiedSendMessage(e, { force: true });
},
reject: () => {
this.focusMessageFieldAndClearDisabled();
},
const dialog = new Whisper.ConfirmationDialogView({
message,
okText: i18n('sendAnyway'),
resolve: () => resolve(true),
reject: () => resolve(false),
});
this.$el.prepend(dialog.el);
dialog.focusCancel();
});
this.$el.prepend(dialog.el);
dialog.focusCancel();
},
async checkUnverifiedSendMessage(e, options = {}) {
async clickSend(e, options) {
e.preventDefault();
this.sendStart = Date.now();
this.$messageField.attr('disabled', true);
_.defaults(options, { force: false });
// This will go to the trust store for the latest identity key information,
// and may result in the display of a new banner for this conversation.
try {
await this.model.updateVerified();
const contacts = this.model.getUnverified();
if (!contacts.length) {
this.checkUntrustedSendMessage(e, options);
const contacts = await this.getUntrustedContacts(options);
if (contacts && contacts.length) {
const sendAnyway = await this.showSendAnywayDialog(contacts);
if (sendAnyway) {
this.clickSend(e, { force: true });
return;
}
this.focusMessageFieldAndClearDisabled();
return;
}
if (options.force) {
await this.markAllAsVerifiedDefault(contacts);
this.checkUnverifiedSendMessage(e, options);
return;
}
this.showSendConfirmationDialog(e, contacts);
this.sendMessage(e);
} catch (error) {
this.focusMessageFieldAndClearDisabled();
window.log.error(
'checkUnverifiedSendMessage error:',
'clickSend error:',
error && error.stack ? error.stack : error
);
}
},
async checkUntrustedSendMessage(e, options = {}) {
_.defaults(options, { force: false });
async sendStickerMessage(options = {}) {
try {
const contacts = await this.model.getUntrusted();
if (!contacts.length) {
this.sendMessage(e);
const contacts = await this.getUntrustedContacts(options);
if (contacts && contacts.length) {
const sendAnyway = await this.showSendAnywayDialog(contacts);
if (sendAnyway) {
this.sendStickerMessage({ ...options, force: true });
}
return;
}
if (options.force) {
await this.markAllAsApproved(contacts);
this.sendMessage(e);
return;
}
this.showSendConfirmationDialog(e, contacts);
const { packId, stickerId } = options;
this.model.sendStickerMessage(packId, stickerId);
} catch (error) {
this.focusMessageFieldAndClearDisabled();
window.log.error(
'checkUntrustedSendMessage error:',
'clickSend error:',
error && error.stack ? error.stack : error
);
}
},
async getUntrustedContacts(options = {}) {
// This will go to the trust store for the latest identity key information,
// and may result in the display of a new banner for this conversation.
await this.model.updateVerified();
const unverifiedContacts = this.model.getUnverified();
if (options.force) {
if (unverifiedContacts.length) {
await this.markAllAsVerifiedDefault(unverifiedContacts);
// We only want force to break us through one layer of checks
// eslint-disable-next-line no-param-reassign
options.force = false;
}
} else if (unverifiedContacts.length) {
return unverifiedContacts;
}
const untrustedContacts = await this.model.getUntrusted();
if (options.force) {
if (untrustedContacts.length) {
await this.markAllAsApproved(untrustedContacts);
}
} else if (untrustedContacts.length) {
return untrustedContacts;
}
return null;
},
toggleEmojiPanel(e) {
e.preventDefault();
if (!this.emojiPanel) {
@ -1839,14 +1940,29 @@
async makeChunkedRequest(url) {
const PARALLELISM = 3;
const size = await textsecure.messaging.getProxiedSize(url);
const chunks = await Signal.LinkPreviews.getChunkPattern(size);
const first = await textsecure.messaging.makeProxiedRequest(url, {
start: 0,
end: Signal.Crypto.getRandomValue(1023, 2047),
returnArrayBuffer: true,
});
const { totalSize, result } = first;
const initialOffset = result.data.byteLength;
const firstChunk = {
start: 0,
end: initialOffset,
...result,
};
const chunks = await Signal.LinkPreviews.getChunkPattern(
totalSize,
initialOffset
);
let results = [];
const jobs = chunks.map(chunk => async () => {
const { start, end } = chunk;
const result = await textsecure.messaging.makeProxiedRequest(url, {
const jobResult = await textsecure.messaging.makeProxiedRequest(url, {
start,
end,
returnArrayBuffer: true,
@ -1854,7 +1970,7 @@
return {
...chunk,
...result,
...jobResult.result,
};
});
@ -1878,7 +1994,9 @@
}
const { contentType } = results[0];
const data = Signal.LinkPreviews.assembleChunks(results);
const data = Signal.LinkPreviews.assembleChunks(
[firstChunk].concat(results)
);
return {
contentType,
@ -1886,7 +2004,58 @@
};
},
async getStickerPackPreview(url) {
const isPackValid = pack =>
pack && (pack.status === 'advertised' || pack.status === 'installed');
try {
const { id, key } = window.Signal.Stickers.getDataFromLink(url);
const keyBytes = window.Signal.Crypto.bytesFromHexString(key);
const keyBase64 = window.Signal.Crypto.arrayBufferToBase64(keyBytes);
const existing = window.Signal.Stickers.getStickerPack(id);
if (!isPackValid(existing)) {
await window.Signal.Stickers.downloadStickerPack(id, keyBase64);
}
const pack = window.Signal.Stickers.getStickerPack(id);
if (!isPackValid(pack)) {
return null;
}
if (pack.key !== keyBase64) {
return null;
}
const { title, coverStickerId } = pack;
const sticker = pack.stickers[coverStickerId];
const data = await window.Signal.Migrations.readStickerData(
sticker.path
);
return {
title,
url,
image: {
...sticker,
data,
size: data.byteLength,
contentType: 'image/webp',
},
};
} catch (error) {
window.log.error(
'getStickerPackPreview error:',
error && error.stack ? error.stack : error
);
return null;
}
},
async getPreview(url) {
if (window.Signal.LinkPreviews.isStickerPack(url)) {
return this.getStickerPackPreview(url);
}
let html;
try {
html = await textsecure.messaging.makeProxiedRequest(url);

View file

@ -13,6 +13,12 @@
window.Whisper = window.Whisper || {};
Whisper.StickerPackInstallFailedToast = Whisper.ToastView.extend({
render_attributes() {
return { toastMessage: i18n('stickers--toast--InstallFailed') };
},
});
Whisper.ConversationStack = Whisper.View.extend({
className: 'conversation-stack',
open(conversation) {
@ -36,6 +42,8 @@
$el.prependTo(this.el);
}
conversation.trigger('opened');
// Make sure poppers are positioned properly
window.dispatchEvent(new Event('resize'));
},
});
@ -92,6 +100,12 @@
this.$el.addClass('expired');
}
Whisper.events.on('pack-install-failed', () => {
const toast = new Whisper.StickerPackInstallFailedToast();
toast.$el.appendTo(this.$el);
toast.render();
});
this.setupLeftPane();
},
render_attributes: {

View file

@ -16,6 +16,12 @@
this.listenTo(this.model, 'destroy', this.onDestroy);
this.listenTo(this.model, 'unload', this.onUnload);
this.listenTo(this.model, 'expired', this.onExpired);
this.updateHiddenSticker();
},
updateHiddenSticker() {
const sticker = this.model.get('sticker');
this.isHiddenSticker = sticker && (!sticker.data || !sticker.data.path);
},
onChange() {
this.addId();
@ -94,7 +100,17 @@
const update = () => {
const info = this.getRenderInfo();
this.childView.update(info.props);
this.childView.update(info.props, () => {
if (!this.isHiddenSticker) {
return;
}
this.updateHiddenSticker();
if (!this.isHiddenSticker) {
this.model.trigger('height-changed');
}
});
};
this.listenTo(this.model, 'change', update);

View file

@ -38,12 +38,23 @@
this.hasRendered = false;
},
update(props) {
update(props, cb) {
const updatedProps = this.augmentProps(props);
const reactElement = this.JSX
? this.JSX
: React.createElement(this.Component, updatedProps);
ReactDOM.render(reactElement, this.el, () => {
if (cb) {
try {
cb();
} catch (error) {
window.log.error(
'ReactWrapperView.update error:',
error && error.stack ? error.stack : error
);
}
}
if (this.hasRendered) {
return;
}