Migrate attachments from IndexedDB to file system (#2129)

- [x] Generate random file names.
- [x] Generate random file paths that prevent too many files per folder using
      fan-out.
- [x] Create attachment directory in user data folder.
- [x] Investigate operating system file indexing on:
  - [x] Windows: Confirmed that `AppData` is not indexed by default.
  - [x] macOS: Confirmed that `~/Library` files are not indexed by default.
        Searching system files using Spotlight requires multi-step opt-in:
        https://lifehacker.com/5711409/how-to-search-for-hidden-packaged-and-system-files-in-os-x.
        More info https://apple.stackexchange.com/a/92785.
        Added `.noindex` suffix to `attachments` folder.
  - [x] Linux: n/a
- [x] Save incoming attachment files to disk
  - [x] On received
  - [x] On sync
- [x] Save outgoing attachments files to disk before sending
- [x] Display attachments either from disk or memory in attachment view.
      Be robust to multiple render passes.
  - [x] Test that missing attachment on disk doesn’t break app.
        Behavior: Message is displayed without attachment.
- [x] Delete attachment files when message is deleted.

Relates to #1589.
pull/1/head
Daniel Gasienica 7 years ago
commit 2e9f3bcf8d

@ -16,6 +16,7 @@ test/views/*.js
# ES2015+ files # ES2015+ files
!js/background.js !js/background.js
!js/backup.js
!js/database.js !js/database.js
!js/logging.js !js/logging.js
!js/models/conversations.js !js/models/conversations.js
@ -24,8 +25,7 @@ test/views/*.js
!js/views/debug_log_view.js !js/views/debug_log_view.js
!js/views/file_input_view.js !js/views/file_input_view.js
!js/views/inbox_view.js !js/views/inbox_view.js
!js/views/message_view.js
!js/views/settings_view.js !js/views/settings_view.js
!js/backup.js
!js/database.js
!main.js !main.js
!prepare_build.js !prepare_build.js

@ -0,0 +1,99 @@
const crypto = require('crypto');
const fse = require('fs-extra');
const isArrayBuffer = require('lodash/isArrayBuffer');
const isString = require('lodash/isString');
const path = require('path');
const toArrayBuffer = require('to-arraybuffer');
const PATH = 'attachments.noindex';
// getPath :: AbsolutePath -> AbsolutePath
exports.getPath = (userDataPath) => {
if (!isString(userDataPath)) {
throw new TypeError('`userDataPath` must be a string');
}
return path.join(userDataPath, PATH);
};
// ensureDirectory :: AbsolutePath -> IO Unit
exports.ensureDirectory = async (userDataPath) => {
if (!isString(userDataPath)) {
throw new TypeError('`userDataPath` must be a string');
}
await fse.ensureDir(exports.getPath(userDataPath));
};
// readData :: AttachmentsPath ->
// RelativePath ->
// IO (Promise ArrayBuffer)
exports.readData = (root) => {
if (!isString(root)) {
throw new TypeError('`root` must be a path');
}
return async (relativePath) => {
if (!isString(relativePath)) {
throw new TypeError('`relativePath` must be a string');
}
const absolutePath = path.join(root, relativePath);
const buffer = await fse.readFile(absolutePath);
return toArrayBuffer(buffer);
};
};
// writeData :: AttachmentsPath ->
// ArrayBuffer ->
// IO (Promise RelativePath)
exports.writeData = (root) => {
if (!isString(root)) {
throw new TypeError('`root` must be a path');
}
return async (arrayBuffer) => {
if (!isArrayBuffer(arrayBuffer)) {
throw new TypeError('`arrayBuffer` must be an array buffer');
}
const buffer = Buffer.from(arrayBuffer);
const name = exports.createName();
const relativePath = exports.getRelativePath(name);
const absolutePath = path.join(root, relativePath);
await fse.ensureFile(absolutePath);
await fse.writeFile(absolutePath, buffer);
return relativePath;
};
};
// deleteData :: AttachmentsPath -> IO Unit
exports.deleteData = (root) => {
if (!isString(root)) {
throw new TypeError('`root` must be a path');
}
return async (relativePath) => {
if (!isString(relativePath)) {
throw new TypeError('`relativePath` must be a string');
}
const absolutePath = path.join(root, relativePath);
await fse.remove(absolutePath);
};
};
// createName :: Unit -> IO String
exports.createName = () => {
const buffer = crypto.randomBytes(32);
return buffer.toString('hex');
};
// getRelativePath :: String -> IO Path
exports.getRelativePath = (name) => {
if (!isString(name)) {
throw new TypeError('`name` must be a string');
}
const prefix = name.slice(0, 2);
return path.join(prefix, name);
};

@ -15,6 +15,7 @@
'use strict'; 'use strict';
const { Errors, Message } = window.Signal.Types; const { Errors, Message } = window.Signal.Types;
const { upgradeMessageSchema } = window.Signal.Migrations;
// Implicitly used in `indexeddb-backbonejs-adapter`: // Implicitly used in `indexeddb-backbonejs-adapter`:
// https://github.com/signalapp/Signal-Desktop/blob/4033a9f8137e62ed286170ed5d4941982b1d3a64/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js#L569 // https://github.com/signalapp/Signal-Desktop/blob/4033a9f8137e62ed286170ed5d4941982b1d3a64/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js#L569
@ -573,7 +574,7 @@
return event.confirm(); return event.confirm();
} }
const upgradedMessage = await Message.upgradeSchema(data.message); const upgradedMessage = await upgradeMessageSchema(data.message);
await ConversationController.getOrCreateAndWait( await ConversationController.getOrCreateAndWait(
messageDescriptor.id, messageDescriptor.id,
messageDescriptor.type messageDescriptor.type

@ -10,6 +10,7 @@
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
const { Attachment, Message } = window.Signal.Types; const { Attachment, Message } = window.Signal.Types;
const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations;
// TODO: Factor out private and group subclasses of Conversation // TODO: Factor out private and group subclasses of Conversation
@ -617,7 +618,7 @@
now now
); );
const messageWithSchema = await Message.upgradeSchema({ const messageWithSchema = await upgradeMessageSchema({
type: 'outgoing', type: 'outgoing',
body, body,
conversationId: this.id, conversationId: this.id,
@ -656,10 +657,12 @@
profileKey = storage.get('profileKey'); profileKey = storage.get('profileKey');
} }
const attachmentsWithData =
await Promise.all(messageWithSchema.attachments.map(loadAttachmentData));
message.send(sendFunction( message.send(sendFunction(
this.get('id'), this.get('id'),
body, body,
messageWithSchema.attachments, attachmentsWithData,
now, now,
this.get('expireTimer'), this.get('expireTimer'),
profileKey profileKey

@ -1,16 +1,22 @@
/* /* eslint-disable */
* vim: ts=4:sw=4:expandtab
*/
(function () { (function () {
'use strict'; 'use strict';
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
const { Attachment, Message: TypedMessage } = window.Signal.Types;
const { deleteAttachmentData } = window.Signal.Migrations;
var Message = window.Whisper.Message = Backbone.Model.extend({ var Message = window.Whisper.Message = Backbone.Model.extend({
database : Whisper.Database, database : Whisper.Database,
storeName : 'messages', storeName : 'messages',
initialize: function() { initialize: function(attributes) {
if (_.isObject(attributes)) {
this.set(TypedMessage.initializeSchemaVersion(attributes));
}
this.on('change:attachments', this.updateImageUrl); this.on('change:attachments', this.updateImageUrl);
this.on('destroy', this.revokeImageUrl); this.on('destroy', this.onDestroy);
this.on('change:expirationStartTimestamp', this.setToExpire); this.on('change:expirationStartTimestamp', this.setToExpire);
this.on('change:expireTimer', this.setToExpire); this.on('change:expireTimer', this.setToExpire);
this.on('unload', this.revokeImageUrl); this.on('unload', this.revokeImageUrl);
@ -136,6 +142,15 @@
return ''; return '';
}, },
/* eslint-enable */
/* jshint ignore:start */
async onDestroy() {
this.revokeImageUrl();
const attachments = this.get('attachments');
await Promise.all(attachments.map(deleteAttachmentData));
},
/* jshint ignore:end */
/* eslint-disable */
updateImageUrl: function() { updateImageUrl: function() {
this.revokeImageUrl(); this.revokeImageUrl();
var attachment = this.get('attachments')[0]; var attachment = this.get('attachments')[0];
@ -427,6 +442,7 @@
} }
} }
message.set({ message.set({
schemaVersion : dataMessage.schemaVersion,
body : dataMessage.body, body : dataMessage.body,
conversationId : conversation.id, conversationId : conversation.id,
attachments : dataMessage.attachments, attachments : dataMessage.attachments,

@ -27,7 +27,7 @@ const REDACTION_PLACEHOLDER = '[REDACTED]';
// redactPhoneNumbers :: String -> String // redactPhoneNumbers :: String -> String
exports.redactPhoneNumbers = (text) => { exports.redactPhoneNumbers = (text) => {
if (!isString(text)) { if (!isString(text)) {
throw new TypeError('`text` must be a string'); throw new TypeError('"text" must be a string');
} }
return text.replace(PHONE_NUMBER_PATTERN, `+${REDACTION_PLACEHOLDER}$1`); return text.replace(PHONE_NUMBER_PATTERN, `+${REDACTION_PLACEHOLDER}$1`);
@ -36,7 +36,7 @@ exports.redactPhoneNumbers = (text) => {
// redactGroupIds :: String -> String // redactGroupIds :: String -> String
exports.redactGroupIds = (text) => { exports.redactGroupIds = (text) => {
if (!isString(text)) { if (!isString(text)) {
throw new TypeError('`text` must be a string'); throw new TypeError('"text" must be a string');
} }
return text.replace( return text.replace(
@ -49,7 +49,7 @@ exports.redactGroupIds = (text) => {
// redactSensitivePaths :: String -> String // redactSensitivePaths :: String -> String
exports.redactSensitivePaths = (text) => { exports.redactSensitivePaths = (text) => {
if (!isString(text)) { if (!isString(text)) {
throw new TypeError('`text` must be a string'); throw new TypeError('"text" must be a string');
} }
if (!isRegExp(APP_ROOT_PATH_PATTERN)) { if (!isRegExp(APP_ROOT_PATH_PATTERN)) {

@ -0,0 +1,11 @@
exports.stringToArrayBuffer = (string) => {
if (typeof string !== 'string') {
throw new TypeError('"string" must be a string');
}
const array = new Uint8Array(string.length);
for (let i = 0; i < string.length; i += 1) {
array[i] = string.charCodeAt(i);
}
return array.buffer;
};

@ -1,8 +1,10 @@
const isFunction = require('lodash/isFunction');
const isString = require('lodash/isString'); const isString = require('lodash/isString');
const MIME = require('./mime'); const MIME = require('./mime');
const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util'); const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util');
const { autoOrientImage } = require('../auto_orient_image'); const { autoOrientImage } = require('../auto_orient_image');
const { migrateDataToFileSystem } = require('./attachment/migrate_data_to_file_system');
// // Incoming message attachment fields // // Incoming message attachment fields
// { // {
@ -107,3 +109,62 @@ exports.removeSchemaVersion = (attachment) => {
delete attachmentWithoutSchemaVersion.schemaVersion; delete attachmentWithoutSchemaVersion.schemaVersion;
return attachmentWithoutSchemaVersion; return attachmentWithoutSchemaVersion;
}; };
exports.migrateDataToFileSystem = migrateDataToFileSystem;
// hasData :: Attachment -> Boolean
exports.hasData = attachment =>
attachment.data instanceof ArrayBuffer || ArrayBuffer.isView(attachment.data);
// loadData :: (RelativePath -> IO (Promise ArrayBuffer))
// Attachment ->
// IO (Promise Attachment)
exports.loadData = (readAttachmentData) => {
if (!isFunction(readAttachmentData)) {
throw new TypeError('"readAttachmentData" must be a function');
}
return async (attachment) => {
if (!exports.isValid(attachment)) {
throw new TypeError('"attachment" is not valid');
}
const isAlreadyLoaded = exports.hasData(attachment);
if (isAlreadyLoaded) {
return attachment;
}
if (!isString(attachment.path)) {
throw new TypeError('"attachment.path" is required');
}
const data = await readAttachmentData(attachment.path);
return Object.assign({}, attachment, { data });
};
};
// deleteData :: (RelativePath -> IO Unit)
// Attachment ->
// IO Unit
exports.deleteData = (deleteAttachmentData) => {
if (!isFunction(deleteAttachmentData)) {
throw new TypeError('"deleteAttachmentData" must be a function');
}
return async (attachment) => {
if (!exports.isValid(attachment)) {
throw new TypeError('"attachment" is not valid');
}
const hasDataInMemory = exports.hasData(attachment);
if (hasDataInMemory) {
return;
}
if (!isString(attachment.path)) {
throw new TypeError('"attachment.path" is required');
}
await deleteAttachmentData(attachment.path);
};
};

@ -0,0 +1,40 @@
const isArrayBuffer = require('lodash/isArrayBuffer');
const isFunction = require('lodash/isFunction');
const isUndefined = require('lodash/isUndefined');
const omit = require('lodash/omit');
// type Context :: {
// writeAttachmentData :: ArrayBuffer -> Promise (IO Path)
// }
//
// migrateDataToFileSystem :: Attachment ->
// Context ->
// Promise Attachment
exports.migrateDataToFileSystem = async (attachment, { writeAttachmentData } = {}) => {
if (!isFunction(writeAttachmentData)) {
throw new TypeError('"writeAttachmentData" must be a function');
}
const { data } = attachment;
const hasData = !isUndefined(data);
const shouldSkipSchemaUpgrade = !hasData;
if (shouldSkipSchemaUpgrade) {
console.log('WARNING: `attachment.data` is `undefined`');
return attachment;
}
const isValidData = isArrayBuffer(data);
if (!isValidData) {
throw new TypeError('Expected `attachment.data` to be an array buffer;' +
` got: ${typeof attachment.data}`);
}
const path = await writeAttachmentData(data);
const attachmentWithoutData = omit(
Object.assign({}, attachment, { path }),
['data']
);
return attachmentWithoutData;
};

@ -13,9 +13,12 @@ const PRIVATE = 'private';
// Version 0 // Version 0
// - Schema initialized // - Schema initialized
// Version 1 // Version 1
// - Attachments: Auto-orient JPEG attachments using EXIF `Orientation` data // - Attachments: Auto-orient JPEG attachments using EXIF `Orientation` data.
// Version 2 // Version 2
// - Attachments: Sanitize Unicode order override characters // - Attachments: Sanitize Unicode order override characters.
// Version 3
// - Attachments: Write attachment data to disk and store relative path to it.
const INITIAL_SCHEMA_VERSION = 0; const INITIAL_SCHEMA_VERSION = 0;
// Increment this version number every time we add a message schema upgrade // Increment this version number every time we add a message schema upgrade
@ -23,7 +26,7 @@ const INITIAL_SCHEMA_VERSION = 0;
// add more upgrade steps, we could design a pipeline that does this // add more upgrade steps, we could design a pipeline that does this
// incrementally, e.g. from version 0 / unknown -> 1, 1 --> 2, etc., similar to // incrementally, e.g. from version 0 / unknown -> 1, 1 --> 2, etc., similar to
// how we do database migrations: // how we do database migrations:
exports.CURRENT_SCHEMA_VERSION = 2; exports.CURRENT_SCHEMA_VERSION = 3;
// Public API // Public API
@ -73,18 +76,18 @@ exports.initializeSchemaVersion = (message) => {
}; };
// Middleware // Middleware
// type UpgradeStep = Message -> Promise Message // type UpgradeStep = (Message, Context) -> Promise Message
// SchemaVersion -> UpgradeStep -> UpgradeStep // SchemaVersion -> UpgradeStep -> UpgradeStep
exports._withSchemaVersion = (schemaVersion, upgrade) => { exports._withSchemaVersion = (schemaVersion, upgrade) => {
if (!SchemaVersion.isValid(schemaVersion)) { if (!SchemaVersion.isValid(schemaVersion)) {
throw new TypeError('`schemaVersion` is invalid'); throw new TypeError('"schemaVersion" is invalid');
} }
if (!isFunction(upgrade)) { if (!isFunction(upgrade)) {
throw new TypeError('`upgrade` must be a function'); throw new TypeError('"upgrade" must be a function');
} }
return async (message) => { return async (message, context) => {
if (!exports.isValid(message)) { if (!exports.isValid(message)) {
console.log('Message._withSchemaVersion: Invalid input message:', message); console.log('Message._withSchemaVersion: Invalid input message:', message);
return message; return message;
@ -109,7 +112,7 @@ exports._withSchemaVersion = (schemaVersion, upgrade) => {
let upgradedMessage; let upgradedMessage;
try { try {
upgradedMessage = await upgrade(message); upgradedMessage = await upgrade(message, context);
} catch (error) { } catch (error) {
console.log( console.log(
'Message._withSchemaVersion: error:', 'Message._withSchemaVersion: error:',
@ -137,16 +140,14 @@ exports._withSchemaVersion = (schemaVersion, upgrade) => {
// Public API // Public API
// _mapAttachments :: (Attachment -> Promise Attachment) -> // _mapAttachments :: (Attachment -> Promise Attachment) ->
// Message -> // (Message, Context) ->
// Promise Message // Promise Message
exports._mapAttachments = upgradeAttachment => async message => exports._mapAttachments = upgradeAttachment => async (message, context) => {
Object.assign( const upgradeWithContext = attachment =>
{}, upgradeAttachment(attachment, context);
message, const attachments = await Promise.all(message.attachments.map(upgradeWithContext));
{ return Object.assign({}, message, { attachments });
attachments: await Promise.all(message.attachments.map(upgradeAttachment)), };
}
);
const toVersion0 = async message => const toVersion0 = async message =>
exports.initializeSchemaVersion(message); exports.initializeSchemaVersion(message);
@ -159,7 +160,14 @@ const toVersion2 = exports._withSchemaVersion(
2, 2,
exports._mapAttachments(Attachment.replaceUnicodeOrderOverrides) exports._mapAttachments(Attachment.replaceUnicodeOrderOverrides)
); );
const toVersion3 = exports._withSchemaVersion(
3,
exports._mapAttachments(Attachment.migrateDataToFileSystem)
);
// UpgradeStep // UpgradeStep
exports.upgradeSchema = async message => exports.upgradeSchema = async (message, { writeAttachmentData } = {}) =>
toVersion2(await toVersion1(await toVersion0(message))); toVersion3(
await toVersion2(await toVersion1(await toVersion0(message))),
{ writeAttachmentData }
);

@ -63,7 +63,7 @@
const VideoView = MediaView.extend({ tagName: 'video' }); const VideoView = MediaView.extend({ tagName: 'video' });
// Blacklist common file types known to be unsupported in Chrome // Blacklist common file types known to be unsupported in Chrome
const UnsupportedFileTypes = [ const unsupportedFileTypes = [
'audio/aiff', 'audio/aiff',
'video/quicktime', 'video/quicktime',
]; ];
@ -86,7 +86,7 @@
} }
}, },
events: { events: {
click: 'onclick', click: 'onClick',
}, },
unload() { unload() {
this.blob = null; this.blob = null;
@ -109,7 +109,7 @@
default: return this.model.contentType.split('/')[1]; default: return this.model.contentType.split('/')[1];
} }
}, },
onclick() { onClick() {
if (this.isImage()) { if (this.isImage()) {
this.lightBoxView = new Whisper.LightboxView({ model: this }); this.lightBoxView = new Whisper.LightboxView({ model: this });
this.lightBoxView.render(); this.lightBoxView.render();
@ -205,7 +205,7 @@
View = VideoView; View = VideoView;
} }
if (!View || _.contains(UnsupportedFileTypes, this.model.contentType)) { if (!View || _.contains(unsupportedFileTypes, this.model.contentType)) {
this.update(); this.update();
return this; return this;
} }

@ -1,10 +1,14 @@
/* /* eslint-disable */
* vim: ts=4:sw=4:expandtab
*/ /* global Whisper: false */
(function () { (function () {
'use strict'; 'use strict';
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
const { Attachment } = window.Signal.Types;
const { loadAttachmentData } = window.Signal.Migrations;
var URL_REGEX = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFD+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi; var URL_REGEX = /(^|[\s\n]|<br\/?>)((?:https?|ftp):\/\/[\-A-Z0-9\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFD+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi;
var ErrorIconView = Whisper.View.extend({ var ErrorIconView = Whisper.View.extend({
@ -178,6 +182,9 @@
return this.model.id; return this.model.id;
}, },
initialize: function() { initialize: function() {
// loadedAttachmentViews :: Promise (Array AttachmentView) | null
this.loadedAttachmentViews = null;
this.listenTo(this.model, 'change:errors', this.onErrorsChanged); this.listenTo(this.model, 'change:errors', this.onErrorsChanged);
this.listenTo(this.model, 'change:body', this.render); this.listenTo(this.model, 'change:body', this.render);
this.listenTo(this.model, 'change:delivered', this.renderDelivered); this.listenTo(this.model, 'change:delivered', this.renderDelivered);
@ -223,6 +230,7 @@
// Failsafe: if in the background, animation events don't fire // Failsafe: if in the background, animation events don't fire
setTimeout(this.remove.bind(this), 1000); setTimeout(this.remove.bind(this), 1000);
}, },
/* jshint ignore:start */
onUnload: function() { onUnload: function() {
if (this.avatarView) { if (this.avatarView) {
this.avatarView.remove(); this.avatarView.remove();
@ -239,18 +247,20 @@
if (this.timeStampView) { if (this.timeStampView) {
this.timeStampView.remove(); this.timeStampView.remove();
} }
if (this.loadedAttachments && this.loadedAttachments.length) {
for (var i = 0, max = this.loadedAttachments.length; i < max; i += 1) { // NOTE: We have to do this in the background (`then` instead of `await`)
var view = this.loadedAttachments[i]; // as our tests rely on `onUnload` synchronously removing the view from
view.unload(); // the DOM.
} // eslint-disable-next-line more/no-then
} this.loadAttachmentViews()
.then(views => views.forEach(view => view.unload()));
// No need to handle this one, since it listens to 'unload' itself: // No need to handle this one, since it listens to 'unload' itself:
// this.timerView // this.timerView
this.remove(); this.remove();
}, },
/* jshint ignore:end */
onDestroy: function() { onDestroy: function() {
if (this.$el.hasClass('expired')) { if (this.$el.hasClass('expired')) {
return; return;
@ -375,7 +385,12 @@
this.renderErrors(); this.renderErrors();
this.renderExpiring(); this.renderExpiring();
this.loadAttachments();
// NOTE: We have to do this in the background (`then` instead of `await`)
// as our code / Backbone seems to rely on `render` synchronously returning
// `this` instead of `Promise MessageView` (this):
// eslint-disable-next-line more/no-then
this.loadAttachmentViews().then(views => this.renderAttachmentViews(views));
return this; return this;
}, },
@ -394,51 +409,61 @@
}))(); }))();
this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar')); this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar'));
}, },
appendAttachmentView: function(view) { /* eslint-enable */
// We check for a truthy 'updated' here to ensure that a race condition in a /* jshint ignore:start */
// multi-fetch() scenario doesn't add an AttachmentView to the DOM before loadAttachmentViews() {
// its 'update' event is triggered. if (this.loadedAttachmentViews !== null) {
var parent = this.$('.attachments')[0]; return this.loadedAttachmentViews;
if (view.updated && parent !== view.el.parentNode) { }
if (view.el.parentNode) {
view.el.parentNode.removeChild(view.el); const attachments = this.model.get('attachments') || [];
} const loadedAttachmentViews = Promise.all(attachments.map(attachment =>
new Promise(async (resolve) => {
this.trigger('beforeChangeHeight'); const attachmentWithData = await loadAttachmentData(attachment);
this.$('.attachments').append(view.el); const view = new Whisper.AttachmentView({
view.setElement(view.el); model: attachmentWithData,
this.trigger('afterChangeHeight'); timestamp: this.model.get('sent_at'),
} });
},
loadAttachments: function() { this.listenTo(view, 'update', () => {
this.loadedAttachments = this.loadedAttachments || []; // NOTE: Can we do without `updated` flag now that we use promises?
view.updated = true;
// If we're called a second time, render() has replaced the DOM out from under resolve(view);
// us with $el.html(). We'll need to reattach our AttachmentViews to the new });
// parent DOM nodes if the 'update' event has already fired.
if (this.loadedAttachments.length) { view.render();
for (var i = 0, max = this.loadedAttachments.length; i < max; i += 1) { })));
var view = this.loadedAttachments[i];
this.appendAttachmentView(view); // Memoize attachment views to avoid double loading:
} this.loadedAttachmentViews = loadedAttachmentViews;
return;
} return loadedAttachmentViews;
},
this.model.get('attachments').forEach(function(attachment) { renderAttachmentViews(views) {
var view = new Whisper.AttachmentView({ views.forEach(view => this.renderAttachmentView(view));
model: attachment, },
timestamp: this.model.get('sent_at') renderAttachmentView(view) {
}); if (!view.updated) {
this.loadedAttachments.push(view); throw new Error('Invariant violation:' +
' Cannot render an attachment view that isnt ready');
this.listenTo(view, 'update', function() { }
view.updated = true;
this.appendAttachmentView(view); const parent = this.$('.attachments')[0];
}); const isViewAlreadyChild = parent === view.el.parentNode;
if (isViewAlreadyChild) {
view.render(); return;
}.bind(this)); }
}
if (view.el.parentNode) {
view.el.parentNode.removeChild(view.el);
}
this.trigger('beforeChangeHeight');
this.$('.attachments').append(view.el);
view.setElement(view.el);
this.trigger('afterChangeHeight');
},
/* jshint ignore:end */
/* eslint-disable */
}); });
})(); })();

@ -87,6 +87,13 @@
}, },
encryptAttachment: function(plaintext, keys, iv) { encryptAttachment: function(plaintext, keys, iv) {
if (!(plaintext instanceof ArrayBuffer) && !ArrayBuffer.isView(plaintext)) {
throw new TypeError(
'`plaintext` must be an `ArrayBuffer` or `ArrayBufferView`; got: ' +
typeof plaintext
);
}
if (keys.byteLength != 64) { if (keys.byteLength != 64) {
throw new Error("Got invalid length attachment keys"); throw new Error("Got invalid length attachment keys");
} }

@ -116,10 +116,21 @@ function MessageSender(url, username, password, cdn_url) {
MessageSender.prototype = { MessageSender.prototype = {
constructor: MessageSender, constructor: MessageSender,
// makeAttachmentPointer :: Attachment -> Promise AttachmentPointerProto
makeAttachmentPointer: function(attachment) { makeAttachmentPointer: function(attachment) {
if (typeof attachment !== 'object' || attachment == null) { if (typeof attachment !== 'object' || attachment == null) {
return Promise.resolve(undefined); return Promise.resolve(undefined);
} }
if (!(attachment.data instanceof ArrayBuffer) &&
!ArrayBuffer.isView(attachment.data)) {
return Promise.reject(new TypeError(
'`attachment.data` must be an `ArrayBuffer` or `ArrayBufferView`; got: ' +
typeof attachment.data
));
}
var proto = new textsecure.protobuf.AttachmentPointer(); var proto = new textsecure.protobuf.AttachmentPointer();
proto.key = libsignal.crypto.getRandomBytes(64); proto.key = libsignal.crypto.getRandomBytes(64);

@ -16,6 +16,7 @@ const {
const packageJson = require('./package.json'); const packageJson = require('./package.json');
const Attachments = require('./app/attachments');
const autoUpdate = require('./app/auto_update'); const autoUpdate = require('./app/auto_update');
const createTrayIcon = require('./app/tray_icon'); const createTrayIcon = require('./app/tray_icon');
const GlobalErrors = require('./js/modules/global_errors'); const GlobalErrors = require('./js/modules/global_errors');
@ -417,7 +418,7 @@ app.on('ready', () => {
let loggingSetupError; let loggingSetupError;
logging.initialize().catch((error) => { logging.initialize().catch((error) => {
loggingSetupError = error; loggingSetupError = error;
}).then(() => { }).then(async () => {
/* eslint-enable more/no-then */ /* eslint-enable more/no-then */
logger = logging.getLogger(); logger = logging.getLogger();
logger.info('app ready'); logger.info('app ready');
@ -431,6 +432,10 @@ app.on('ready', () => {
locale = loadLocale({ appLocale, logger }); locale = loadLocale({ appLocale, logger });
} }
console.log('Ensure attachments directory exists');
const userDataPath = app.getPath('userData');
await Attachments.ensureDirectory(userDataPath);
ready = true; ready = true;
autoUpdate.initialize(getMainWindow, locale.messages); autoUpdate.initialize(getMainWindow, locale.messages);

@ -62,6 +62,7 @@
"emoji-panel": "https://github.com/scottnonnenberg/emoji-panel.git#v0.5.5", "emoji-panel": "https://github.com/scottnonnenberg/emoji-panel.git#v0.5.5",
"firstline": "^1.2.1", "firstline": "^1.2.1",
"form-data": "^2.3.2", "form-data": "^2.3.2",
"fs-extra": "^5.0.0",
"google-libphonenumber": "^3.0.7", "google-libphonenumber": "^3.0.7",
"got": "^8.2.0", "got": "^8.2.0",
"lodash": "^4.17.4", "lodash": "^4.17.4",
@ -77,6 +78,7 @@
"spellchecker": "^3.4.4", "spellchecker": "^3.4.4",
"testcheck": "^1.0.0-rc.2", "testcheck": "^1.0.0-rc.2",
"tmp": "^0.0.33", "tmp": "^0.0.33",
"to-arraybuffer": "^1.0.1",
"websocket": "^1.0.25" "websocket": "^1.0.25"
}, },
"devDependencies": { "devDependencies": {

@ -4,6 +4,13 @@
console.log('preload'); console.log('preload');
const electron = require('electron'); const electron = require('electron');
const Attachment = require('./js/modules/types/attachment');
const Attachments = require('./app/attachments');
const Message = require('./js/modules/types/message');
const { app } = electron.remote;
window.PROTO_ROOT = 'protos'; window.PROTO_ROOT = 'protos';
window.config = require('url').parse(window.location.toString(), true).query; window.config = require('url').parse(window.location.toString(), true).query;
window.wrapDeferred = function(deferred) { window.wrapDeferred = function(deferred) {
@ -103,19 +110,32 @@
window.autoOrientImage = autoOrientImage; window.autoOrientImage = autoOrientImage;
// ES2015+ modules // ES2015+ modules
const attachmentsPath = Attachments.getPath(app.getPath('userData'));
const deleteAttachmentData = Attachments.deleteData(attachmentsPath);
const readAttachmentData = Attachments.readData(attachmentsPath);
const writeAttachmentData = Attachments.writeData(attachmentsPath);
// Injected context functions to keep `Message` agnostic from Electron:
const upgradeSchemaContext = {
writeAttachmentData,
};
const upgradeMessageSchema = message =>
Message.upgradeSchema(message, upgradeSchemaContext);
window.Signal = window.Signal || {}; window.Signal = window.Signal || {};
window.Signal.Logs = require('./js/modules/logs'); window.Signal.Logs = require('./js/modules/logs');
window.Signal.OS = require('./js/modules/os'); window.Signal.OS = require('./js/modules/os');
window.Signal.Backup = require('./js/modules/backup'); window.Signal.Backup = require('./js/modules/backup');
window.Signal.Crypto = require('./js/modules/crypto'); window.Signal.Crypto = require('./js/modules/crypto');
window.Signal.Migrations = {};
window.Signal.Migrations = window.Signal.Migrations || {}; window.Signal.Migrations.loadAttachmentData = Attachment.loadData(readAttachmentData);
window.Signal.Migrations.deleteAttachmentData = Attachment.deleteData(deleteAttachmentData);
window.Signal.Migrations.upgradeMessageSchema = upgradeMessageSchema;
window.Signal.Migrations.V17 = require('./js/modules/migrations/17'); window.Signal.Migrations.V17 = require('./js/modules/migrations/17');
window.Signal.Types = window.Signal.Types || {}; window.Signal.Types = window.Signal.Types || {};
window.Signal.Types.Attachment = require('./js/modules/types/attachment'); window.Signal.Types.Attachment = Attachment;
window.Signal.Types.Errors = require('./js/modules/types/errors'); window.Signal.Types.Errors = require('./js/modules/types/errors');
window.Signal.Types.Message = require('./js/modules/types/message'); window.Signal.Types.Message = Message;
window.Signal.Types.MIME = require('./js/modules/types/mime'); window.Signal.Types.MIME = require('./js/modules/types/mime');
window.Signal.Types.Settings = require('./js/modules/types/settings'); window.Signal.Types.Settings = require('./js/modules/types/settings');

@ -0,0 +1,105 @@
const fse = require('fs-extra');
const path = require('path');
const tmp = require('tmp');
const { assert } = require('chai');
const Attachments = require('../../app/attachments');
const { stringToArrayBuffer } = require('../../js/modules/string_to_array_buffer');
const PREFIX_LENGTH = 2;
const NUM_SEPARATORS = 1;
const NAME_LENGTH = 64;
const PATH_LENGTH = PREFIX_LENGTH + NUM_SEPARATORS + NAME_LENGTH;
describe('Attachments', () => {
describe('writeData', () => {
let tempRootDirectory = null;
before(() => {
tempRootDirectory = tmp.dirSync().name;
});
after(async () => {
await fse.remove(tempRootDirectory);
});
it('should write file to disk and return path', async () => {
const input = stringToArrayBuffer('test string');
const tempDirectory = path.join(tempRootDirectory, 'Attachments_writeData');
const outputPath = await Attachments.writeData(tempDirectory)(input);
const output = await fse.readFile(path.join(tempDirectory, outputPath));
assert.lengthOf(outputPath, PATH_LENGTH);
const inputBuffer = Buffer.from(input);
assert.deepEqual(inputBuffer, output);
});
});
describe('readData', () => {
let tempRootDirectory = null;
before(() => {
tempRootDirectory = tmp.dirSync().name;
});
after(async () => {
await fse.remove(tempRootDirectory);
});
it('should read file from disk', async () => {
const tempDirectory = path.join(tempRootDirectory, 'Attachments_readData');
const relativePath = Attachments.getRelativePath(Attachments.createName());
const fullPath = path.join(tempDirectory, relativePath);
const input = stringToArrayBuffer('test string');
const inputBuffer = Buffer.from(input);
await fse.ensureFile(fullPath);
await fse.writeFile(fullPath, inputBuffer);
const output = await Attachments.readData(tempDirectory)(relativePath);
assert.deepEqual(input, output);
});
});
describe('deleteData', () => {
let tempRootDirectory = null;
before(() => {
tempRootDirectory = tmp.dirSync().name;
});
after(async () => {
await fse.remove(tempRootDirectory);
});
it('should delete file from disk', async () => {
const tempDirectory = path.join(tempRootDirectory, 'Attachments_deleteData');
const relativePath = Attachments.getRelativePath(Attachments.createName());
const fullPath = path.join(tempDirectory, relativePath);
const input = stringToArrayBuffer('test string');
const inputBuffer = Buffer.from(input);
await fse.ensureFile(fullPath);
await fse.writeFile(fullPath, inputBuffer);
await Attachments.deleteData(tempDirectory)(relativePath);
const existsFile = await fse.exists(fullPath);
assert.isFalse(existsFile);
});
});
describe('createName', () => {
it('should return random file name with correct length', () => {
assert.lengthOf(Attachments.createName(), NAME_LENGTH);
});
});
describe('getRelativePath', () => {
it('should return correct path', () => {
const name = '608ce3bc536edbf7637a6aeb6040bdfec49349140c0dd43e97c7ce263b15ff7e';
assert.lengthOf(Attachments.getRelativePath(name), PATH_LENGTH);
});
});
});

@ -3,6 +3,7 @@ require('mocha-testcheck').install();
const { assert } = require('chai'); const { assert } = require('chai');
const Attachment = require('../../../js/modules/types/attachment'); const Attachment = require('../../../js/modules/types/attachment');
const { stringToArrayBuffer } = require('../../../js/modules/string_to_array_buffer');
describe('Attachment', () => { describe('Attachment', () => {
describe('replaceUnicodeOrderOverrides', () => { describe('replaceUnicodeOrderOverrides', () => {
@ -101,4 +102,81 @@ describe('Attachment', () => {
assert.deepEqual(actual, expected); assert.deepEqual(actual, expected);
}); });
}); });
describe('migrateDataToFileSystem', () => {
it('should write data to disk and store relative path to it', async () => {
const input = {
contentType: 'image/jpeg',
data: stringToArrayBuffer('Above us only sky'),
fileName: 'foo.jpg',
size: 1111,
};
const expected = {
contentType: 'image/jpeg',
path: 'abc/abcdefgh123456789',
fileName: 'foo.jpg',
size: 1111,
};
const expectedAttachmentData = stringToArrayBuffer('Above us only sky');
const writeAttachmentData = async (attachmentData) => {
assert.deepEqual(attachmentData, expectedAttachmentData);
return 'abc/abcdefgh123456789';
};
const actual = await Attachment.migrateDataToFileSystem(
input,
{ writeAttachmentData }
);
assert.deepEqual(actual, expected);
});
it('should skip over (invalid) attachments without data', async () => {
const input = {
contentType: 'image/jpeg',
fileName: 'foo.jpg',
size: 1111,
};
const expected = {
contentType: 'image/jpeg',
fileName: 'foo.jpg',
size: 1111,
};
const writeAttachmentData = async () =>
'abc/abcdefgh123456789';
const actual = await Attachment.migrateDataToFileSystem(
input,
{ writeAttachmentData }
);
assert.deepEqual(actual, expected);
});
it('should throw error if data is not valid', async () => {
const input = {
contentType: 'image/jpeg',
data: 42,
fileName: 'foo.jpg',
size: 1111,
};
const writeAttachmentData = async () =>
'abc/abcdefgh123456789';
try {
await Attachment.migrateDataToFileSystem(input, { writeAttachmentData });
} catch (error) {
assert.strictEqual(
error.message,
'Expected `attachment.data` to be an array buffer; got: number'
);
return;
}
assert.fail('Unreachable');
});
});
}); });

@ -1,6 +1,7 @@
const { assert } = require('chai'); const { assert } = require('chai');
const Message = require('../../../js/modules/types/message'); const Message = require('../../../js/modules/types/message');
const { stringToArrayBuffer } = require('../../../js/modules/string_to_array_buffer');
describe('Message', () => { describe('Message', () => {
@ -66,7 +67,7 @@ describe('Message', () => {
const input = { const input = {
attachments: [{ attachments: [{
contentType: 'application/json', contentType: 'application/json',
data: null, data: stringToArrayBuffer('Its easy if you try'),
fileName: 'test\u202Dfig.exe', fileName: 'test\u202Dfig.exe',
size: 1111, size: 1111,
}], }],
@ -75,14 +76,21 @@ describe('Message', () => {
const expected = { const expected = {
attachments: [{ attachments: [{
contentType: 'application/json', contentType: 'application/json',
data: null, path: 'abc/abcdefg',
fileName: 'test\uFFFDfig.exe', fileName: 'test\uFFFDfig.exe',
size: 1111, size: 1111,
}], }],
schemaVersion: Message.CURRENT_SCHEMA_VERSION, schemaVersion: Message.CURRENT_SCHEMA_VERSION,
}; };
const actual = await Message.upgradeSchema(input); const expectedAttachmentData = stringToArrayBuffer('Its easy if you try');
const context = {
writeAttachmentData: async (attachmentData) => {
assert.deepEqual(attachmentData, expectedAttachmentData);
return 'abc/abcdefg';
},
};
const actual = await Message.upgradeSchema(input, context);
assert.deepEqual(actual, expected); assert.deepEqual(actual, expected);
}); });
@ -175,14 +183,14 @@ describe('Message', () => {
const toVersionX = () => {}; const toVersionX = () => {};
assert.throws( assert.throws(
() => Message._withSchemaVersion(toVersionX, 2), () => Message._withSchemaVersion(toVersionX, 2),
'`schemaVersion` is invalid' '"schemaVersion" is invalid'
); );
}); });
it('should require an upgrade function', () => { it('should require an upgrade function', () => {
assert.throws( assert.throws(
() => Message._withSchemaVersion(2, 3), () => Message._withSchemaVersion(2, 3),
'`upgrade` must be a function' '"upgrade" must be a function'
); );
}); });

@ -5427,6 +5427,10 @@ tmp@^0.0.33:
dependencies: dependencies:
os-tmpdir "~1.0.2" os-tmpdir "~1.0.2"
to-arraybuffer@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43"
to-double-quotes@^2.0.0: to-double-quotes@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/to-double-quotes/-/to-double-quotes-2.0.0.tgz#aaf231d6fa948949f819301bbab4484d8588e4a7" resolved "https://registry.yarnpkg.com/to-double-quotes/-/to-double-quotes-2.0.0.tgz#aaf231d6fa948949f819301bbab4484d8588e4a7"

Loading…
Cancel
Save