1339 lines
35 KiB
JavaScript
1339 lines
35 KiB
JavaScript
/* global Signal: false */
|
|
/* global Whisper: false */
|
|
/* global _: false */
|
|
/* global textsecure: false */
|
|
/* global i18n: false */
|
|
|
|
/* eslint-env browser */
|
|
/* eslint-env node */
|
|
|
|
/* eslint-disable no-param-reassign, guard-for-in */
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { map, fromPairs } = require('lodash');
|
|
const tar = require('tar');
|
|
const tmp = require('tmp');
|
|
const pify = require('pify');
|
|
const rimraf = require('rimraf');
|
|
const electronRemote = require('electron').remote;
|
|
|
|
const crypto = require('./crypto');
|
|
|
|
const { dialog, BrowserWindow } = electronRemote;
|
|
|
|
module.exports = {
|
|
getDirectoryForExport,
|
|
exportToDirectory,
|
|
getDirectoryForImport,
|
|
importFromDirectory,
|
|
// for testing
|
|
_sanitizeFileName,
|
|
_trimFileName,
|
|
_getExportAttachmentFileName,
|
|
_getAnonymousAttachmentFileName,
|
|
_getConversationDirName,
|
|
_getConversationLoggingName,
|
|
};
|
|
|
|
function stringify(object) {
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for (const key in object) {
|
|
const val = object[key];
|
|
if (val instanceof ArrayBuffer) {
|
|
object[key] = {
|
|
type: 'ArrayBuffer',
|
|
encoding: 'base64',
|
|
data: crypto.arrayBufferToBase64(val),
|
|
};
|
|
} else if (val instanceof Object) {
|
|
object[key] = stringify(val);
|
|
}
|
|
}
|
|
return object;
|
|
}
|
|
|
|
function unstringify(object) {
|
|
if (!(object instanceof Object)) {
|
|
throw new Error('unstringify expects an object');
|
|
}
|
|
// eslint-disable-next-line no-restricted-syntax
|
|
for (const key in object) {
|
|
const val = object[key];
|
|
if (
|
|
val &&
|
|
val.type === 'ArrayBuffer' &&
|
|
val.encoding === 'base64' &&
|
|
typeof val.data === 'string'
|
|
) {
|
|
object[key] = crypto.base64ToArrayBuffer(val.data);
|
|
} else if (val instanceof Object) {
|
|
object[key] = unstringify(object[key]);
|
|
}
|
|
}
|
|
return object;
|
|
}
|
|
|
|
function createOutputStream(writer) {
|
|
let wait = Promise.resolve();
|
|
return {
|
|
write(string) {
|
|
// eslint-disable-next-line more/no-then
|
|
wait = wait.then(
|
|
() =>
|
|
new Promise(resolve => {
|
|
if (writer.write(string)) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
// If write() returns true, we don't need to wait for the drain event
|
|
// https://nodejs.org/dist/latest-v7.x/docs/api/stream.html#stream_class_stream_writable
|
|
writer.once('drain', resolve);
|
|
|
|
// We don't register for the 'error' event here, only in close(). Otherwise,
|
|
// we'll get "Possible EventEmitter memory leak detected" warnings.
|
|
})
|
|
);
|
|
return wait;
|
|
},
|
|
async close() {
|
|
await wait;
|
|
return new Promise((resolve, reject) => {
|
|
writer.once('finish', resolve);
|
|
writer.once('error', reject);
|
|
writer.end();
|
|
});
|
|
},
|
|
};
|
|
}
|
|
|
|
async function exportConversationListToFile(parent) {
|
|
const writer = await createFileAndWriter(parent, 'db.json');
|
|
return exportConversationList(writer);
|
|
}
|
|
|
|
function writeArray(stream, array) {
|
|
stream.write('[');
|
|
|
|
for (let i = 0, max = array.length; i < max; i += 1) {
|
|
if (i > 0) {
|
|
stream.write(',');
|
|
}
|
|
|
|
const item = array[i];
|
|
|
|
// We don't back up avatars; we'll get them in a future contact sync or profile fetch
|
|
const cleaned = _.omit(item, ['avatar', 'profileAvatar']);
|
|
|
|
stream.write(JSON.stringify(stringify(cleaned)));
|
|
}
|
|
|
|
stream.write(']');
|
|
}
|
|
|
|
function getPlainJS(collection) {
|
|
return collection.map(model => model.attributes);
|
|
}
|
|
|
|
async function exportConversationList(fileWriter) {
|
|
const stream = createOutputStream(fileWriter);
|
|
|
|
stream.write('{');
|
|
|
|
stream.write('"conversations": ');
|
|
const conversations = await window.Signal.Data.getAllConversations({
|
|
ConversationCollection: Whisper.ConversationCollection,
|
|
});
|
|
window.log.info(`Exporting ${conversations.length} conversations`);
|
|
writeArray(stream, getPlainJS(conversations));
|
|
|
|
stream.write('}');
|
|
await stream.close();
|
|
}
|
|
|
|
async function importNonMessages(parent, options) {
|
|
const file = 'db.json';
|
|
const string = await readFileAsText(parent, file);
|
|
return importFromJsonString(string, path.join(parent, file), options);
|
|
}
|
|
|
|
function eliminateClientConfigInBackup(data, targetPath) {
|
|
const cleaned = _.pick(data, 'conversations');
|
|
window.log.info('Writing configuration-free backup file back to disk');
|
|
try {
|
|
fs.writeFileSync(targetPath, JSON.stringify(cleaned));
|
|
} catch (error) {
|
|
window.log.error('Error writing cleaned-up backup to disk: ', error.stack);
|
|
}
|
|
}
|
|
|
|
async function importConversationsFromJSON(conversations, options) {
|
|
const { writeNewAttachmentData } = window.Signal.Migrations;
|
|
const { conversationLookup } = options;
|
|
|
|
let count = 0;
|
|
let skipCount = 0;
|
|
|
|
for (let i = 0, max = conversations.length; i < max; i += 1) {
|
|
const toAdd = unstringify(conversations[i]);
|
|
const haveConversationAlready =
|
|
conversationLookup[getConversationKey(toAdd)];
|
|
|
|
if (haveConversationAlready) {
|
|
skipCount += 1;
|
|
count += 1;
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
|
|
count += 1;
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const migrated = await window.Signal.Types.Conversation.migrateConversation(
|
|
toAdd,
|
|
{
|
|
writeNewAttachmentData,
|
|
}
|
|
);
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await window.Signal.Data.saveConversation(migrated, {
|
|
Conversation: Whisper.Conversation,
|
|
});
|
|
}
|
|
|
|
window.log.info(
|
|
'Done importing conversations:',
|
|
'Total count:',
|
|
count,
|
|
'Skipped:',
|
|
skipCount
|
|
);
|
|
}
|
|
|
|
async function importFromJsonString(jsonString, targetPath, options) {
|
|
options = options || {};
|
|
_.defaults(options, {
|
|
forceLightImport: false,
|
|
conversationLookup: {},
|
|
});
|
|
|
|
const result = {
|
|
fullImport: true,
|
|
};
|
|
|
|
const importObject = JSON.parse(jsonString);
|
|
delete importObject.debug;
|
|
|
|
if (!importObject.sessions || options.forceLightImport) {
|
|
result.fullImport = false;
|
|
|
|
delete importObject.items;
|
|
delete importObject.signedPreKeys;
|
|
delete importObject.preKeys;
|
|
delete importObject.identityKeys;
|
|
delete importObject.sessions;
|
|
delete importObject.unprocessed;
|
|
|
|
window.log.info(
|
|
'This is a light import; contacts, groups and messages only'
|
|
);
|
|
}
|
|
|
|
// We mutate the on-disk backup to prevent the user from importing client
|
|
// configuration more than once - that causes lots of encryption errors.
|
|
// This of course preserves the true data: conversations.
|
|
eliminateClientConfigInBackup(importObject, targetPath);
|
|
|
|
const storeNames = _.keys(importObject);
|
|
window.log.info('Importing to these stores:', storeNames.join(', '));
|
|
|
|
// Special-case conversations key here, going to SQLCipher
|
|
const { conversations } = importObject;
|
|
const remainingStoreNames = _.without(
|
|
storeNames,
|
|
'conversations',
|
|
'unprocessed',
|
|
'groups' // in old data sets, but no longer included in database schema
|
|
);
|
|
await importConversationsFromJSON(conversations, options);
|
|
|
|
const SAVE_FUNCTIONS = {
|
|
identityKeys: window.Signal.Data.createOrUpdateIdentityKey,
|
|
items: window.Signal.Data.createOrUpdateItem,
|
|
preKeys: window.Signal.Data.createOrUpdatePreKey,
|
|
sessions: window.Signal.Data.createOrUpdateSession,
|
|
signedPreKeys: window.Signal.Data.createOrUpdateSignedPreKey,
|
|
};
|
|
|
|
await Promise.all(
|
|
_.map(remainingStoreNames, async storeName => {
|
|
const save = SAVE_FUNCTIONS[storeName];
|
|
if (!_.isFunction(save)) {
|
|
throw new Error(
|
|
`importFromJsonString: Didn't have save function for store ${storeName}`
|
|
);
|
|
}
|
|
|
|
window.log.info(`Importing items for store ${storeName}`);
|
|
const toImport = importObject[storeName];
|
|
|
|
if (!toImport || !toImport.length) {
|
|
window.log.info(`No items in ${storeName} store`);
|
|
return;
|
|
}
|
|
|
|
for (let i = 0, max = toImport.length; i < max; i += 1) {
|
|
const toAdd = unstringify(toImport[i]);
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await save(toAdd);
|
|
}
|
|
|
|
window.log.info(
|
|
'Done importing to store',
|
|
storeName,
|
|
'Total count:',
|
|
toImport.length
|
|
);
|
|
})
|
|
);
|
|
|
|
window.log.info('DB import complete');
|
|
return result;
|
|
}
|
|
|
|
function createDirectory(parent, name) {
|
|
return new Promise((resolve, reject) => {
|
|
const sanitized = _sanitizeFileName(name);
|
|
const targetDir = path.join(parent, sanitized);
|
|
if (fs.existsSync(targetDir)) {
|
|
resolve(targetDir);
|
|
return;
|
|
}
|
|
|
|
fs.mkdir(targetDir, error => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
resolve(targetDir);
|
|
});
|
|
});
|
|
}
|
|
|
|
function createFileAndWriter(parent, name) {
|
|
return new Promise(resolve => {
|
|
const sanitized = _sanitizeFileName(name);
|
|
const targetPath = path.join(parent, sanitized);
|
|
const options = {
|
|
flags: 'wx',
|
|
};
|
|
return resolve(fs.createWriteStream(targetPath, options));
|
|
});
|
|
}
|
|
|
|
function readFileAsText(parent, name) {
|
|
return new Promise((resolve, reject) => {
|
|
const targetPath = path.join(parent, name);
|
|
fs.readFile(targetPath, 'utf8', (error, string) => {
|
|
if (error) {
|
|
return reject(error);
|
|
}
|
|
|
|
return resolve(string);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Buffer instances are also Uint8Array instances, but they might be a view
|
|
// https://nodejs.org/docs/latest/api/buffer.html#buffer_buffers_and_typedarray
|
|
const toArrayBuffer = nodeBuffer =>
|
|
nodeBuffer.buffer.slice(
|
|
nodeBuffer.byteOffset,
|
|
nodeBuffer.byteOffset + nodeBuffer.byteLength
|
|
);
|
|
|
|
function readFileAsArrayBuffer(targetPath) {
|
|
return new Promise((resolve, reject) => {
|
|
// omitting the encoding to get a buffer back
|
|
fs.readFile(targetPath, (error, buffer) => {
|
|
if (error) {
|
|
return reject(error);
|
|
}
|
|
|
|
return resolve(toArrayBuffer(buffer));
|
|
});
|
|
});
|
|
}
|
|
|
|
function _trimFileName(filename) {
|
|
const components = filename.split('.');
|
|
if (components.length <= 1) {
|
|
return filename.slice(0, 30);
|
|
}
|
|
|
|
const extension = components[components.length - 1];
|
|
const name = components.slice(0, components.length - 1);
|
|
if (extension.length > 5) {
|
|
return filename.slice(0, 30);
|
|
}
|
|
|
|
return `${name.join('.').slice(0, 24)}.${extension}`;
|
|
}
|
|
|
|
function _getExportAttachmentFileName(message, index, attachment) {
|
|
if (attachment.fileName) {
|
|
return _trimFileName(attachment.fileName);
|
|
}
|
|
|
|
let name = attachment.id;
|
|
|
|
if (attachment.contentType) {
|
|
const components = attachment.contentType.split('/');
|
|
name += `.${
|
|
components.length > 1 ? components[1] : attachment.contentType
|
|
}`;
|
|
}
|
|
|
|
return name;
|
|
}
|
|
|
|
function _getAnonymousAttachmentFileName(message, index) {
|
|
if (!index) {
|
|
return message.id;
|
|
}
|
|
return `${message.id}-${index}`;
|
|
}
|
|
|
|
async function readEncryptedAttachment(dir, attachment, name, options) {
|
|
options = options || {};
|
|
const { key } = options;
|
|
|
|
const sanitizedName = _sanitizeFileName(name);
|
|
const targetPath = path.join(dir, sanitizedName);
|
|
|
|
if (!fs.existsSync(targetPath)) {
|
|
window.log.warn(`Warning: attachment ${sanitizedName} not found`);
|
|
return;
|
|
}
|
|
|
|
const data = await readFileAsArrayBuffer(targetPath);
|
|
|
|
const isEncrypted = !_.isUndefined(key);
|
|
|
|
if (isEncrypted) {
|
|
attachment.data = await crypto.decryptAttachment(
|
|
key,
|
|
attachment.path,
|
|
data
|
|
);
|
|
} else {
|
|
attachment.data = data;
|
|
}
|
|
}
|
|
|
|
async function writeQuoteThumbnail(attachment, options) {
|
|
if (!attachment || !attachment.thumbnail || !attachment.thumbnail.path) {
|
|
return;
|
|
}
|
|
|
|
const { dir, message, index, key, newKey } = options;
|
|
const filename = `${_getAnonymousAttachmentFileName(
|
|
message,
|
|
index
|
|
)}-quote-thumbnail`;
|
|
const target = path.join(dir, filename);
|
|
|
|
await writeEncryptedAttachment(target, attachment.thumbnail.path, {
|
|
key,
|
|
newKey,
|
|
filename,
|
|
dir,
|
|
});
|
|
}
|
|
|
|
async function writeQuoteThumbnails(quotedAttachments, options) {
|
|
const { name } = options;
|
|
|
|
try {
|
|
await Promise.all(
|
|
_.map(quotedAttachments, (attachment, index) =>
|
|
writeQuoteThumbnail(
|
|
attachment,
|
|
Object.assign({}, options, {
|
|
index,
|
|
})
|
|
)
|
|
)
|
|
);
|
|
} catch (error) {
|
|
window.log.error(
|
|
'writeThumbnails: error exporting conversation',
|
|
name,
|
|
':',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function writeAttachment(attachment, options) {
|
|
if (!_.isString(attachment.path)) {
|
|
throw new Error('writeAttachment: attachment.path was not a string!');
|
|
}
|
|
|
|
const { dir, message, index, key, newKey } = options;
|
|
const filename = _getAnonymousAttachmentFileName(message, index);
|
|
const target = path.join(dir, filename);
|
|
|
|
await writeEncryptedAttachment(target, attachment.path, {
|
|
key,
|
|
newKey,
|
|
filename,
|
|
dir,
|
|
});
|
|
|
|
if (attachment.thumbnail && _.isString(attachment.thumbnail.path)) {
|
|
const thumbnailName = `${_getAnonymousAttachmentFileName(
|
|
message,
|
|
index
|
|
)}-thumbnail`;
|
|
const thumbnailTarget = path.join(dir, thumbnailName);
|
|
await writeEncryptedAttachment(thumbnailTarget, attachment.thumbnail.path, {
|
|
key,
|
|
newKey,
|
|
filename: thumbnailName,
|
|
dir,
|
|
});
|
|
}
|
|
|
|
if (attachment.screenshot && _.isString(attachment.screenshot.path)) {
|
|
const screenshotName = `${_getAnonymousAttachmentFileName(
|
|
message,
|
|
index
|
|
)}-screenshot`;
|
|
const screenshotTarget = path.join(dir, screenshotName);
|
|
await writeEncryptedAttachment(
|
|
screenshotTarget,
|
|
attachment.screenshot.path,
|
|
{
|
|
key,
|
|
newKey,
|
|
filename: screenshotName,
|
|
dir,
|
|
}
|
|
);
|
|
}
|
|
}
|
|
|
|
async function writeAttachments(attachments, options) {
|
|
const { name } = options;
|
|
|
|
const promises = _.map(attachments, (attachment, index) =>
|
|
writeAttachment(
|
|
attachment,
|
|
Object.assign({}, options, {
|
|
index,
|
|
})
|
|
)
|
|
);
|
|
try {
|
|
await Promise.all(promises);
|
|
} catch (error) {
|
|
window.log.error(
|
|
'writeAttachments: error exporting conversation',
|
|
name,
|
|
':',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function writeAvatar(contact, options) {
|
|
const { avatar } = contact || {};
|
|
if (!avatar || !avatar.avatar || !avatar.avatar.path) {
|
|
return;
|
|
}
|
|
|
|
const { dir, message, index, key, newKey } = options;
|
|
const name = _getAnonymousAttachmentFileName(message, index);
|
|
const filename = `${name}-contact-avatar`;
|
|
const target = path.join(dir, filename);
|
|
|
|
await writeEncryptedAttachment(target, avatar.avatar.path, {
|
|
key,
|
|
newKey,
|
|
filename,
|
|
dir,
|
|
});
|
|
}
|
|
|
|
async function writeContactAvatars(contact, options) {
|
|
const { name } = options;
|
|
|
|
try {
|
|
await Promise.all(
|
|
_.map(contact, (item, index) =>
|
|
writeAvatar(
|
|
item,
|
|
Object.assign({}, options, {
|
|
index,
|
|
})
|
|
)
|
|
)
|
|
);
|
|
} catch (error) {
|
|
window.log.error(
|
|
'writeContactAvatars: error exporting conversation',
|
|
name,
|
|
':',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function writePreviewImage(preview, options) {
|
|
const { image } = preview || {};
|
|
if (!image || !image.path) {
|
|
return;
|
|
}
|
|
|
|
const { dir, message, index, key, newKey } = options;
|
|
const name = _getAnonymousAttachmentFileName(message, index);
|
|
const filename = `${name}-preview`;
|
|
const target = path.join(dir, filename);
|
|
|
|
await writeEncryptedAttachment(target, image.path, {
|
|
key,
|
|
newKey,
|
|
filename,
|
|
dir,
|
|
});
|
|
}
|
|
|
|
async function writePreviews(preview, options) {
|
|
const { name } = options;
|
|
|
|
try {
|
|
await Promise.all(
|
|
_.map(preview, (item, index) =>
|
|
writePreviewImage(
|
|
item,
|
|
Object.assign({}, options, {
|
|
index,
|
|
})
|
|
)
|
|
)
|
|
);
|
|
} catch (error) {
|
|
window.log.error(
|
|
'writePreviews: error exporting conversation',
|
|
name,
|
|
':',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function writeEncryptedAttachment(target, source, options = {}) {
|
|
const { key, newKey, filename, dir } = options;
|
|
|
|
if (fs.existsSync(target)) {
|
|
if (newKey) {
|
|
window.log.info(`Deleting attachment ${filename}; key has changed`);
|
|
fs.unlinkSync(target);
|
|
} else {
|
|
window.log.info(`Skipping attachment ${filename}; already exists`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
const { readAttachmentData } = Signal.Migrations;
|
|
const data = await readAttachmentData(source);
|
|
const ciphertext = await crypto.encryptAttachment(key, source, data);
|
|
|
|
const writer = await createFileAndWriter(dir, filename);
|
|
const stream = createOutputStream(writer);
|
|
stream.write(Buffer.from(ciphertext));
|
|
await stream.close();
|
|
}
|
|
|
|
function _sanitizeFileName(filename) {
|
|
return filename.toString().replace(/[^a-z0-9.,+()'#\- ]/gi, '_');
|
|
}
|
|
|
|
async function exportConversation(conversation, options = {}) {
|
|
const { name, dir, attachmentsDir, key, newKey } = options;
|
|
|
|
if (!name) {
|
|
throw new Error('Need a name!');
|
|
}
|
|
if (!dir) {
|
|
throw new Error('Need a target directory!');
|
|
}
|
|
if (!attachmentsDir) {
|
|
throw new Error('Need an attachments directory!');
|
|
}
|
|
if (!key) {
|
|
throw new Error('Need a key to encrypt with!');
|
|
}
|
|
|
|
window.log.info('exporting conversation', name);
|
|
const writer = await createFileAndWriter(dir, 'messages.json');
|
|
const stream = createOutputStream(writer);
|
|
stream.write('{"messages":[');
|
|
|
|
const CHUNK_SIZE = 50;
|
|
let count = 0;
|
|
let complete = false;
|
|
|
|
// We're looping from the most recent to the oldest
|
|
let lastReceivedAt = Number.MAX_VALUE;
|
|
|
|
while (!complete) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const collection = await window.Signal.Data.getOlderMessagesByConversation(
|
|
conversation.id,
|
|
{
|
|
limit: CHUNK_SIZE,
|
|
receivedAt: lastReceivedAt,
|
|
MessageCollection: Whisper.MessageCollection,
|
|
}
|
|
);
|
|
const messages = getPlainJS(collection);
|
|
|
|
for (let i = 0, max = messages.length; i < max; i += 1) {
|
|
const message = messages[i];
|
|
if (count > 0) {
|
|
stream.write(',');
|
|
}
|
|
|
|
count += 1;
|
|
|
|
// skip message if it is disappearing, no matter the amount of time left
|
|
if (message.expireTimer || message.messageTimer || message.isViewOnce) {
|
|
// eslint-disable-next-line no-continue
|
|
continue;
|
|
}
|
|
|
|
const { attachments } = message;
|
|
// eliminate attachment data from the JSON, since it will go to disk
|
|
// Note: this is for legacy messages only, which stored attachment data in the db
|
|
message.attachments = _.map(attachments, attachment =>
|
|
_.omit(attachment, ['data'])
|
|
);
|
|
// completely drop any attachments in messages cached in error objects
|
|
// TODO: move to lodash. Sadly, a number of the method signatures have changed!
|
|
message.errors = _.map(message.errors, error => {
|
|
if (error && error.args) {
|
|
error.args = [];
|
|
}
|
|
if (error && error.stack) {
|
|
error.stack = '';
|
|
}
|
|
return error;
|
|
});
|
|
|
|
const jsonString = JSON.stringify(stringify(message));
|
|
stream.write(jsonString);
|
|
|
|
if (attachments && attachments.length > 0) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await writeAttachments(attachments, {
|
|
dir: attachmentsDir,
|
|
name,
|
|
message,
|
|
key,
|
|
newKey,
|
|
});
|
|
}
|
|
|
|
const quoteThumbnails = message.quote && message.quote.attachments;
|
|
if (quoteThumbnails && quoteThumbnails.length > 0) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await writeQuoteThumbnails(quoteThumbnails, {
|
|
dir: attachmentsDir,
|
|
name,
|
|
message,
|
|
key,
|
|
newKey,
|
|
});
|
|
}
|
|
|
|
const { contact } = message;
|
|
if (contact && contact.length > 0) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await writeContactAvatars(contact, {
|
|
dir: attachmentsDir,
|
|
name,
|
|
message,
|
|
key,
|
|
newKey,
|
|
});
|
|
}
|
|
|
|
const { preview } = message;
|
|
if (preview && preview.length > 0) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await writePreviews(preview, {
|
|
dir: attachmentsDir,
|
|
name,
|
|
message,
|
|
key,
|
|
newKey,
|
|
});
|
|
}
|
|
}
|
|
|
|
const last = messages.length > 0 ? messages[messages.length - 1] : null;
|
|
if (last) {
|
|
lastReceivedAt = last.received_at;
|
|
}
|
|
|
|
if (messages.length < CHUNK_SIZE) {
|
|
complete = true;
|
|
}
|
|
}
|
|
|
|
stream.write(']}');
|
|
await stream.close();
|
|
}
|
|
|
|
// Goals for directory names:
|
|
// 1. Human-readable, for easy use and verification by user (names not just ids)
|
|
// 2. Sorted just like the list of conversations in the left-pan (active_at)
|
|
// 3. Disambiguated from other directories (active_at, truncated name, id)
|
|
function _getConversationDirName(conversation) {
|
|
const name = conversation.active_at || 'inactive';
|
|
if (conversation.name) {
|
|
return `${name} (${conversation.name.slice(0, 30)} ${conversation.id})`;
|
|
}
|
|
return `${name} (${conversation.id})`;
|
|
}
|
|
|
|
// Goals for logging names:
|
|
// 1. Can be associated with files on disk
|
|
// 2. Adequately disambiguated to enable debugging flow of execution
|
|
// 3. Can be shared to the web without privacy concerns (there's no global redaction
|
|
// logic for group ids, so we do it manually here)
|
|
function _getConversationLoggingName(conversation) {
|
|
let name = conversation.active_at || 'inactive';
|
|
if (conversation.type === 'private') {
|
|
name += ` (${conversation.id})`;
|
|
} else {
|
|
name += ` ([REDACTED_GROUP]${conversation.id.slice(-3)})`;
|
|
}
|
|
return name;
|
|
}
|
|
|
|
async function exportConversations(options) {
|
|
options = options || {};
|
|
const { messagesDir, attachmentsDir, key, newKey } = options;
|
|
|
|
if (!messagesDir) {
|
|
throw new Error('Need a messages directory!');
|
|
}
|
|
if (!attachmentsDir) {
|
|
throw new Error('Need an attachments directory!');
|
|
}
|
|
|
|
const collection = await window.Signal.Data.getAllConversations({
|
|
ConversationCollection: Whisper.ConversationCollection,
|
|
});
|
|
const conversations = collection.models;
|
|
|
|
for (let i = 0, max = conversations.length; i < max; i += 1) {
|
|
const conversation = conversations[i];
|
|
const dirName = _getConversationDirName(conversation);
|
|
const name = _getConversationLoggingName(conversation);
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const dir = await createDirectory(messagesDir, dirName);
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await exportConversation(conversation, {
|
|
name,
|
|
dir,
|
|
attachmentsDir,
|
|
key,
|
|
newKey,
|
|
});
|
|
}
|
|
|
|
window.log.info('Done exporting conversations!');
|
|
}
|
|
|
|
function getDirectory(options = {}) {
|
|
return new Promise((resolve, reject) => {
|
|
const browserWindow = BrowserWindow.getFocusedWindow();
|
|
const dialogOptions = {
|
|
title: options.title,
|
|
properties: ['openDirectory'],
|
|
buttonLabel: options.buttonLabel,
|
|
};
|
|
|
|
dialog.showOpenDialog(browserWindow, dialogOptions, directory => {
|
|
if (!directory || !directory[0]) {
|
|
const error = new Error('Error choosing directory');
|
|
error.name = 'ChooseError';
|
|
return reject(error);
|
|
}
|
|
|
|
return resolve(directory[0]);
|
|
});
|
|
});
|
|
}
|
|
|
|
function getDirContents(dir) {
|
|
return new Promise((resolve, reject) => {
|
|
fs.readdir(dir, (err, files) => {
|
|
if (err) {
|
|
reject(err);
|
|
return;
|
|
}
|
|
|
|
files = _.map(files, file => path.join(dir, file));
|
|
|
|
resolve(files);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadAttachments(dir, getName, options) {
|
|
options = options || {};
|
|
const { message } = options;
|
|
|
|
await Promise.all(
|
|
_.map(message.attachments, async (attachment, index) => {
|
|
const name = getName(message, index, attachment);
|
|
|
|
await readEncryptedAttachment(dir, attachment, name, options);
|
|
|
|
if (attachment.thumbnail && _.isString(attachment.thumbnail.path)) {
|
|
const thumbnailName = `${name}-thumbnail`;
|
|
await readEncryptedAttachment(
|
|
dir,
|
|
attachment.thumbnail,
|
|
thumbnailName,
|
|
options
|
|
);
|
|
}
|
|
|
|
if (attachment.screenshot && _.isString(attachment.screenshot.path)) {
|
|
const screenshotName = `${name}-screenshot`;
|
|
await readEncryptedAttachment(
|
|
dir,
|
|
attachment.screenshot,
|
|
screenshotName,
|
|
options
|
|
);
|
|
}
|
|
})
|
|
);
|
|
|
|
const quoteAttachments = message.quote && message.quote.attachments;
|
|
await Promise.all(
|
|
_.map(quoteAttachments, (attachment, index) => {
|
|
const thumbnail = attachment && attachment.thumbnail;
|
|
if (!thumbnail) {
|
|
return null;
|
|
}
|
|
|
|
const name = `${getName(message, index)}-quote-thumbnail`;
|
|
return readEncryptedAttachment(dir, thumbnail, name, options);
|
|
})
|
|
);
|
|
|
|
const { contact } = message;
|
|
await Promise.all(
|
|
_.map(contact, (item, index) => {
|
|
const avatar = item && item.avatar && item.avatar.avatar;
|
|
if (!avatar) {
|
|
return null;
|
|
}
|
|
|
|
const name = `${getName(message, index)}-contact-avatar`;
|
|
return readEncryptedAttachment(dir, avatar, name, options);
|
|
})
|
|
);
|
|
|
|
const { preview } = message;
|
|
await Promise.all(
|
|
_.map(preview, (item, index) => {
|
|
const image = item && item.image;
|
|
if (!image) {
|
|
return null;
|
|
}
|
|
|
|
const name = `${getName(message, index)}-preview`;
|
|
return readEncryptedAttachment(dir, image, name, options);
|
|
})
|
|
);
|
|
}
|
|
|
|
function saveMessage(message) {
|
|
return saveAllMessages([message]);
|
|
}
|
|
|
|
async function saveAllMessages(rawMessages) {
|
|
if (rawMessages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const { writeMessageAttachments, upgradeMessageSchema } = Signal.Migrations;
|
|
const importAndUpgrade = async message =>
|
|
upgradeMessageSchema(await writeMessageAttachments(message));
|
|
|
|
const messages = await Promise.all(rawMessages.map(importAndUpgrade));
|
|
|
|
const { conversationId } = messages[0];
|
|
|
|
await window.Signal.Data.saveMessages(messages, {
|
|
forceSave: true,
|
|
});
|
|
|
|
window.log.info(
|
|
'Saved',
|
|
messages.length,
|
|
'messages for conversation',
|
|
// Don't know if group or private conversation, so we blindly redact
|
|
`[REDACTED]${conversationId.slice(-3)}`
|
|
);
|
|
} catch (error) {
|
|
window.log.error(
|
|
'saveAllMessages error',
|
|
error && error.message ? error.message : error
|
|
);
|
|
}
|
|
}
|
|
|
|
// To reduce the memory impact of attachments, we make individual saves to the
|
|
// database for every message with an attachment. We load the attachment for a
|
|
// message, save it, and only then do we move on to the next message. Thus, every
|
|
// message with attachments needs to be removed from our overall message save with the
|
|
// filter() call.
|
|
async function importConversation(dir, options) {
|
|
options = options || {};
|
|
_.defaults(options, { messageLookup: {} });
|
|
|
|
const { messageLookup, attachmentsDir, key } = options;
|
|
|
|
let conversationId = 'unknown';
|
|
let total = 0;
|
|
let skipped = 0;
|
|
let contents;
|
|
|
|
try {
|
|
contents = await readFileAsText(dir, 'messages.json');
|
|
} catch (error) {
|
|
window.log.error(
|
|
`Warning: could not access messages.json in directory: ${dir}`
|
|
);
|
|
}
|
|
|
|
let promiseChain = Promise.resolve();
|
|
|
|
const json = JSON.parse(contents);
|
|
if (json.messages && json.messages.length) {
|
|
conversationId = `[REDACTED]${(json.messages[0].conversationId || '').slice(
|
|
-3
|
|
)}`;
|
|
}
|
|
total = json.messages.length;
|
|
|
|
const messages = _.filter(json.messages, message => {
|
|
message = unstringify(message);
|
|
|
|
if (messageLookup[getMessageKey(message)]) {
|
|
skipped += 1;
|
|
return false;
|
|
}
|
|
|
|
const hasAttachments = message.attachments && message.attachments.length;
|
|
const hasQuotedAttachments =
|
|
message.quote &&
|
|
message.quote.attachments &&
|
|
message.quote.attachments.length > 0;
|
|
const hasContacts = message.contact && message.contact.length;
|
|
const hasPreviews = message.preview && message.preview.length;
|
|
|
|
if (hasAttachments || hasQuotedAttachments || hasContacts || hasPreviews) {
|
|
const importMessage = async () => {
|
|
const getName = attachmentsDir
|
|
? _getAnonymousAttachmentFileName
|
|
: _getExportAttachmentFileName;
|
|
const parentDir =
|
|
attachmentsDir || path.join(dir, message.received_at.toString());
|
|
|
|
await loadAttachments(parentDir, getName, {
|
|
message,
|
|
key,
|
|
});
|
|
return saveMessage(message);
|
|
};
|
|
|
|
// eslint-disable-next-line more/no-then
|
|
promiseChain = promiseChain.then(importMessage);
|
|
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
await saveAllMessages(messages);
|
|
|
|
await promiseChain;
|
|
window.log.info(
|
|
'Finished importing conversation',
|
|
conversationId,
|
|
'Total:',
|
|
total,
|
|
'Skipped:',
|
|
skipped
|
|
);
|
|
}
|
|
|
|
async function importConversations(dir, options) {
|
|
const contents = await getDirContents(dir);
|
|
let promiseChain = Promise.resolve();
|
|
|
|
_.forEach(contents, conversationDir => {
|
|
if (!fs.statSync(conversationDir).isDirectory()) {
|
|
return;
|
|
}
|
|
|
|
const loadConversation = () => importConversation(conversationDir, options);
|
|
|
|
// eslint-disable-next-line more/no-then
|
|
promiseChain = promiseChain.then(loadConversation);
|
|
});
|
|
|
|
return promiseChain;
|
|
}
|
|
|
|
function getMessageKey(message) {
|
|
const ourNumber = textsecure.storage.user.getNumber();
|
|
const source = message.source || ourNumber;
|
|
if (source === ourNumber) {
|
|
return `${source} ${message.timestamp}`;
|
|
}
|
|
|
|
const sourceDevice = message.sourceDevice || 1;
|
|
return `${source}.${sourceDevice} ${message.timestamp}`;
|
|
}
|
|
async function loadMessagesLookup() {
|
|
const array = await window.Signal.Data.getAllMessageIds();
|
|
return fromPairs(map(array, item => [getMessageKey(item), true]));
|
|
}
|
|
|
|
function getConversationKey(conversation) {
|
|
return conversation.id;
|
|
}
|
|
async function loadConversationLookup() {
|
|
const array = await window.Signal.Data.getAllConversationIds();
|
|
return fromPairs(map(array, item => [getConversationKey(item), true]));
|
|
}
|
|
|
|
function getDirectoryForExport() {
|
|
return getDirectory();
|
|
}
|
|
|
|
async function compressArchive(file, targetDir) {
|
|
const items = fs.readdirSync(targetDir);
|
|
return tar.c(
|
|
{
|
|
gzip: true,
|
|
file,
|
|
cwd: targetDir,
|
|
},
|
|
items
|
|
);
|
|
}
|
|
|
|
async function decompressArchive(file, targetDir) {
|
|
return tar.x({
|
|
file,
|
|
cwd: targetDir,
|
|
});
|
|
}
|
|
|
|
function writeFile(targetPath, contents) {
|
|
return pify(fs.writeFile)(targetPath, contents);
|
|
}
|
|
|
|
// prettier-ignore
|
|
const UNIQUE_ID = new Uint8Array([
|
|
1, 3, 4, 5, 6, 7, 8, 11,
|
|
23, 34, 1, 34, 3, 5, 45, 45,
|
|
1, 3, 4, 5, 6, 7, 8, 11,
|
|
23, 34, 1, 34, 3, 5, 45, 45,
|
|
]);
|
|
async function encryptFile(sourcePath, targetPath, options) {
|
|
options = options || {};
|
|
|
|
const { key } = options;
|
|
if (!key) {
|
|
throw new Error('Need key to do encryption!');
|
|
}
|
|
|
|
const plaintext = await readFileAsArrayBuffer(sourcePath);
|
|
const ciphertext = await crypto.encryptFile(key, UNIQUE_ID, plaintext);
|
|
return writeFile(targetPath, Buffer.from(ciphertext));
|
|
}
|
|
|
|
async function decryptFile(sourcePath, targetPath, options) {
|
|
options = options || {};
|
|
|
|
const { key } = options;
|
|
if (!key) {
|
|
throw new Error('Need key to do encryption!');
|
|
}
|
|
|
|
const ciphertext = await readFileAsArrayBuffer(sourcePath);
|
|
const plaintext = await crypto.decryptFile(key, UNIQUE_ID, ciphertext);
|
|
return writeFile(targetPath, Buffer.from(plaintext));
|
|
}
|
|
|
|
function createTempDir() {
|
|
return pify(tmp.dir)();
|
|
}
|
|
|
|
function deleteAll(pattern) {
|
|
return pify(rimraf)(pattern);
|
|
}
|
|
|
|
const ARCHIVE_NAME = 'messages.tar.gz';
|
|
|
|
async function exportToDirectory(directory, options) {
|
|
const env = window.getEnvironment();
|
|
if (env !== 'test') {
|
|
throw new Error('export is only supported in test mode');
|
|
}
|
|
|
|
options = options || {};
|
|
|
|
if (!options.key) {
|
|
throw new Error('Encrypted backup requires a key to encrypt with!');
|
|
}
|
|
|
|
let stagingDir;
|
|
let encryptionDir;
|
|
try {
|
|
stagingDir = await createTempDir();
|
|
encryptionDir = await createTempDir();
|
|
|
|
const attachmentsDir = await createDirectory(directory, 'attachments');
|
|
|
|
await exportConversationListToFile(stagingDir);
|
|
await exportConversations(
|
|
Object.assign({}, options, {
|
|
messagesDir: stagingDir,
|
|
attachmentsDir,
|
|
})
|
|
);
|
|
|
|
const archivePath = path.join(directory, ARCHIVE_NAME);
|
|
await compressArchive(archivePath, stagingDir);
|
|
await encryptFile(archivePath, path.join(directory, ARCHIVE_NAME), options);
|
|
|
|
window.log.info('done backing up!');
|
|
return directory;
|
|
} catch (error) {
|
|
window.log.error(
|
|
'The backup went wrong!',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
throw error;
|
|
} finally {
|
|
if (stagingDir) {
|
|
await deleteAll(stagingDir);
|
|
}
|
|
if (encryptionDir) {
|
|
await deleteAll(encryptionDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getDirectoryForImport() {
|
|
const options = {
|
|
title: i18n('importChooserTitle'),
|
|
};
|
|
return getDirectory(options);
|
|
}
|
|
|
|
async function importFromDirectory(directory, options) {
|
|
options = options || {};
|
|
|
|
try {
|
|
const lookups = await Promise.all([
|
|
loadMessagesLookup(),
|
|
loadConversationLookup(),
|
|
]);
|
|
const [messageLookup, conversationLookup] = lookups;
|
|
options = Object.assign({}, options, {
|
|
messageLookup,
|
|
conversationLookup,
|
|
});
|
|
|
|
const archivePath = path.join(directory, ARCHIVE_NAME);
|
|
if (fs.existsSync(archivePath)) {
|
|
const env = window.getEnvironment();
|
|
if (env !== 'test') {
|
|
throw new Error('import is only supported in test mode');
|
|
}
|
|
|
|
// we're in the world of an encrypted, zipped backup
|
|
if (!options.key) {
|
|
throw new Error(
|
|
'Importing an encrypted backup; decryption key is required!'
|
|
);
|
|
}
|
|
|
|
let stagingDir;
|
|
let decryptionDir;
|
|
try {
|
|
stagingDir = await createTempDir();
|
|
decryptionDir = await createTempDir();
|
|
|
|
const attachmentsDir = path.join(directory, 'attachments');
|
|
|
|
const decryptedArchivePath = path.join(decryptionDir, ARCHIVE_NAME);
|
|
await decryptFile(archivePath, decryptedArchivePath, options);
|
|
await decompressArchive(decryptedArchivePath, stagingDir);
|
|
|
|
options = Object.assign({}, options, {
|
|
attachmentsDir,
|
|
});
|
|
const result = await importNonMessages(stagingDir, options);
|
|
await importConversations(stagingDir, Object.assign({}, options));
|
|
|
|
window.log.info('Done importing from backup!');
|
|
return result;
|
|
} finally {
|
|
if (stagingDir) {
|
|
await deleteAll(stagingDir);
|
|
}
|
|
if (decryptionDir) {
|
|
await deleteAll(decryptionDir);
|
|
}
|
|
}
|
|
}
|
|
|
|
const result = await importNonMessages(directory, options);
|
|
await importConversations(directory, options);
|
|
|
|
window.log.info('Done importing!');
|
|
return result;
|
|
} catch (error) {
|
|
window.log.error(
|
|
'The import went wrong!',
|
|
error && error.stack ? error.stack : error
|
|
);
|
|
throw error;
|
|
}
|
|
}
|