705 lines
18 KiB
JavaScript
705 lines
18 KiB
JavaScript
/* global
|
|
textsecure,
|
|
Signal,
|
|
log,
|
|
navigator,
|
|
reduxStore,
|
|
reduxActions,
|
|
URL
|
|
*/
|
|
|
|
const BLESSED_PACKS = {};
|
|
|
|
const { isNumber, pick, reject, groupBy, values } = 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,
|
|
downloadEphemeralPack,
|
|
getDataFromLink,
|
|
getInitialState,
|
|
getInstalledStickerPacks,
|
|
getSticker,
|
|
getStickerPack,
|
|
getStickerPackStatus,
|
|
load,
|
|
maybeDeletePack,
|
|
downloadQueuedPacks,
|
|
redactPackId,
|
|
removeEphemeralPack,
|
|
savePackMetadata,
|
|
};
|
|
|
|
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 items = Object.values(packs);
|
|
return items.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 !== 'downloaded' && 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];
|
|
|
|
// These packs should never end up in the database, but if they do we'll delete them
|
|
if (existing.status === 'ephemeral') {
|
|
deletePack(id);
|
|
return;
|
|
}
|
|
|
|
// We don't automatically download these; not until a user action kicks it off
|
|
if (existing.status === 'known') {
|
|
return;
|
|
}
|
|
|
|
if (doesPackNeedDownload(existing)) {
|
|
const status =
|
|
existing.attemptedStatus === 'installed' ? 'installed' : null;
|
|
toDownload[id] = {
|
|
id,
|
|
key: existing.key,
|
|
status,
|
|
};
|
|
}
|
|
});
|
|
|
|
return toDownload;
|
|
}
|
|
|
|
function doesPackNeedDownload(pack) {
|
|
if (!pack) {
|
|
return true;
|
|
}
|
|
|
|
const { status, stickerCount } = pack;
|
|
const stickersDownloaded = Object.keys(pack.stickers || {}).length;
|
|
|
|
if (
|
|
(status === 'installed' || status === 'downloaded') &&
|
|
stickerCount > 0 &&
|
|
stickersDownloaded >= stickerCount
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// If we don't understand a pack's status, we'll download it
|
|
// If a pack has any other status, we'll download it
|
|
// If a pack has zero stickers in it, we'll download it
|
|
// If a pack doesn't have enough downloaded stickers, we'll download it
|
|
|
|
return true;
|
|
}
|
|
|
|
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, options) {
|
|
const { ephemeral } = options || {};
|
|
|
|
const ciphertext = await textsecure.messaging.getSticker(packId, proto.id);
|
|
const plaintext = await decryptSticker(packKey, ciphertext);
|
|
|
|
const sticker = ephemeral
|
|
? await Signal.Migrations.processNewEphemeralSticker(plaintext, options)
|
|
: await Signal.Migrations.processNewSticker(plaintext, options);
|
|
|
|
return {
|
|
...pick(proto, ['id', 'emoji']),
|
|
...sticker,
|
|
packId,
|
|
};
|
|
}
|
|
|
|
async function savePackMetadata(packId, packKey, options = {}) {
|
|
const { messageId } = options;
|
|
|
|
const existing = getStickerPack(packId);
|
|
if (existing) {
|
|
return;
|
|
}
|
|
|
|
const { stickerPackAdded } = getReduxStickerActions();
|
|
const pack = {
|
|
id: packId,
|
|
key: packKey,
|
|
status: 'known',
|
|
};
|
|
stickerPackAdded(pack);
|
|
|
|
await createOrUpdateStickerPack(pack);
|
|
if (messageId) {
|
|
await addStickerPackReference(messageId, packId);
|
|
}
|
|
}
|
|
|
|
async function removeEphemeralPack(packId) {
|
|
const existing = getStickerPack(packId);
|
|
if (
|
|
existing.status !== 'ephemeral' &&
|
|
!(existing.status === 'error' && existing.attemptedStatus === 'ephemeral')
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const { removeStickerPack } = getReduxStickerActions();
|
|
removeStickerPack(packId);
|
|
|
|
const stickers = values(existing.stickers);
|
|
const paths = stickers.map(sticker => sticker.path);
|
|
await pMap(paths, Signal.Migrations.deleteTempFile, {
|
|
concurrency: 3,
|
|
});
|
|
|
|
// Remove it from database in case it made it there
|
|
await deleteStickerPack(packId);
|
|
}
|
|
|
|
async function downloadEphemeralPack(packId, packKey) {
|
|
const {
|
|
stickerAdded,
|
|
stickerPackAdded,
|
|
stickerPackUpdated,
|
|
} = getReduxStickerActions();
|
|
|
|
const existingPack = getStickerPack(packId);
|
|
if (existingPack) {
|
|
log.warn(
|
|
`Ephemeral download for pack ${redactPackId(
|
|
packId
|
|
)} requested, we already know about it. Skipping.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Synchronous placeholder to help with race conditions
|
|
const placeholder = {
|
|
id: packId,
|
|
key: packKey,
|
|
status: 'ephemeral',
|
|
};
|
|
stickerPackAdded(placeholder);
|
|
|
|
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;
|
|
|
|
const coverProto = proto.cover || firstStickerProto;
|
|
const 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`
|
|
);
|
|
}
|
|
|
|
const nonCoverStickers = reject(
|
|
proto.stickers,
|
|
sticker => !isNumber(sticker.id) || sticker.id === coverStickerId
|
|
);
|
|
|
|
const coverIncludedInList = nonCoverStickers.length < stickerCount;
|
|
|
|
const pack = {
|
|
id: packId,
|
|
key: packKey,
|
|
coverStickerId,
|
|
stickerCount,
|
|
status: 'ephemeral',
|
|
...pick(proto, ['title', 'author']),
|
|
};
|
|
stickerPackAdded(pack);
|
|
|
|
const downloadStickerJob = async stickerProto => {
|
|
const stickerInfo = await downloadSticker(packId, packKey, stickerProto, {
|
|
ephemeral: true,
|
|
});
|
|
const sticker = {
|
|
...stickerInfo,
|
|
isCoverOnly: !coverIncludedInList && stickerInfo.id === coverStickerId,
|
|
};
|
|
|
|
const statusCheck = getStickerPackStatus(packId);
|
|
if (statusCheck !== 'ephemeral') {
|
|
throw new Error(
|
|
`Ephemeral download for pack ${redactPackId(
|
|
packId
|
|
)} interrupted by status change. Status is now ${statusCheck}.`
|
|
);
|
|
}
|
|
|
|
stickerAdded(sticker);
|
|
};
|
|
|
|
// Download the cover first
|
|
await downloadStickerJob(coverProto);
|
|
|
|
// Then the rest
|
|
await pMap(nonCoverStickers, downloadStickerJob, { concurrency: 3 });
|
|
} catch (error) {
|
|
// Because the user could install this pack while we are still downloading this
|
|
// ephemeral pack, we don't want to go change its status unless we're still in
|
|
// ephemeral mode.
|
|
const statusCheck = getStickerPackStatus(packId);
|
|
if (statusCheck === 'ephemeral') {
|
|
stickerPackUpdated(packId, {
|
|
attemptedStatus: 'ephemeral',
|
|
status: 'error',
|
|
});
|
|
}
|
|
log.error(
|
|
`Ephemeral download error for sticker pack ${redactPackId(packId)}:`,
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
}
|
|
}
|
|
|
|
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 || 'downloaded';
|
|
if (finalStatus !== 'downloaded' && finalStatus !== 'installed') {
|
|
throw new Error(
|
|
`doDownloadStickerPack: invalid finalStatus of ${finalStatus} requested.`
|
|
);
|
|
}
|
|
|
|
const existing = getStickerPack(packId);
|
|
if (!doesPackNeedDownload(existing)) {
|
|
log.warn(
|
|
`Download for pack ${redactPackId(
|
|
packId
|
|
)} requested, but it does not need re-download. Skipping.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// We don't count this as an attempt if we're offline
|
|
const attemptIncrement = navigator.onLine ? 1 : 0;
|
|
const downloadAttempts =
|
|
(existing ? existing.downloadAttempts || 0 : 0) + attemptIncrement;
|
|
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 {
|
|
// Synchronous placeholder to help with race conditions
|
|
const placeholder = {
|
|
id: packId,
|
|
key: packKey,
|
|
attemptedStatus: finalStatus,
|
|
downloadAttempts,
|
|
status: 'pending',
|
|
};
|
|
stickerPackAdded(placeholder);
|
|
|
|
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:
|
|
// - 'known'
|
|
// - 'ephemeral' (should not hit database)
|
|
// - 'pending'
|
|
// - 'downloaded'
|
|
// - '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 });
|
|
|
|
// Allow for the user marking this pack as installed in the middle of our download;
|
|
// don't overwrite that status.
|
|
const existingStatus = getStickerPackStatus(packId);
|
|
if (existingStatus === 'installed') {
|
|
return;
|
|
}
|
|
|
|
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 errorStatus = 'error';
|
|
await updateStickerPackStatus(packId, errorStatus);
|
|
if (stickerPackUpdated) {
|
|
stickerPackUpdated(packId, {
|
|
attemptedStatus: finalStatus,
|
|
status: errorStatus,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|