diff --git a/.eslintignore b/.eslintignore index c7ae533b0..99d95ea00 100644 --- a/.eslintignore +++ b/.eslintignore @@ -16,6 +16,7 @@ test/views/*.js # ES2015+ files !js/background.js +!js/backup.js !js/database.js !js/logging.js !js/models/conversations.js @@ -24,8 +25,7 @@ test/views/*.js !js/views/debug_log_view.js !js/views/file_input_view.js !js/views/inbox_view.js +!js/views/message_view.js !js/views/settings_view.js -!js/backup.js -!js/database.js !main.js !prepare_build.js diff --git a/app/attachments.js b/app/attachments.js new file mode 100644 index 000000000..0b7b5a068 --- /dev/null +++ b/app/attachments.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); +}; diff --git a/js/background.js b/js/background.js index 734bc9284..ca2a59ffa 100644 --- a/js/background.js +++ b/js/background.js @@ -15,6 +15,7 @@ 'use strict'; const { Errors, Message } = window.Signal.Types; + const { upgradeMessageSchema } = window.Signal.Migrations; // Implicitly used in `indexeddb-backbonejs-adapter`: // https://github.com/signalapp/Signal-Desktop/blob/4033a9f8137e62ed286170ed5d4941982b1d3a64/components/indexeddb-backbonejs-adapter/backbone-indexeddb.js#L569 @@ -573,7 +574,7 @@ return event.confirm(); } - const upgradedMessage = await Message.upgradeSchema(data.message); + const upgradedMessage = await upgradeMessageSchema(data.message); await ConversationController.getOrCreateAndWait( messageDescriptor.id, messageDescriptor.type diff --git a/js/models/conversations.js b/js/models/conversations.js index d24882a77..72ff82394 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -10,6 +10,7 @@ window.Whisper = window.Whisper || {}; const { Attachment, Message } = window.Signal.Types; + const { upgradeMessageSchema, loadAttachmentData } = window.Signal.Migrations; // TODO: Factor out private and group subclasses of Conversation @@ -617,7 +618,7 @@ now ); - const messageWithSchema = await Message.upgradeSchema({ + const messageWithSchema = await upgradeMessageSchema({ type: 'outgoing', body, conversationId: this.id, @@ -656,10 +657,12 @@ profileKey = storage.get('profileKey'); } + const attachmentsWithData = + await Promise.all(messageWithSchema.attachments.map(loadAttachmentData)); message.send(sendFunction( this.get('id'), body, - messageWithSchema.attachments, + attachmentsWithData, now, this.get('expireTimer'), profileKey diff --git a/js/models/messages.js b/js/models/messages.js index 3e7ea0a70..1022c1428 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1,16 +1,22 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* eslint-disable */ + (function () { 'use strict'; window.Whisper = window.Whisper || {}; + const { Attachment, Message: TypedMessage } = window.Signal.Types; + const { deleteAttachmentData } = window.Signal.Migrations; + var Message = window.Whisper.Message = Backbone.Model.extend({ database : Whisper.Database, storeName : 'messages', - initialize: function() { + initialize: function(attributes) { + if (_.isObject(attributes)) { + this.set(TypedMessage.initializeSchemaVersion(attributes)); + } + 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:expireTimer', this.setToExpire); this.on('unload', this.revokeImageUrl); @@ -136,6 +142,15 @@ 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() { this.revokeImageUrl(); var attachment = this.get('attachments')[0]; @@ -427,6 +442,7 @@ } } message.set({ + schemaVersion : dataMessage.schemaVersion, body : dataMessage.body, conversationId : conversation.id, attachments : dataMessage.attachments, diff --git a/js/modules/privacy.js b/js/modules/privacy.js index db0529376..9e5f26646 100644 --- a/js/modules/privacy.js +++ b/js/modules/privacy.js @@ -27,7 +27,7 @@ const REDACTION_PLACEHOLDER = '[REDACTED]'; // redactPhoneNumbers :: String -> String exports.redactPhoneNumbers = (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`); @@ -36,7 +36,7 @@ exports.redactPhoneNumbers = (text) => { // redactGroupIds :: String -> String exports.redactGroupIds = (text) => { if (!isString(text)) { - throw new TypeError('`text` must be a string'); + throw new TypeError('"text" must be a string'); } return text.replace( @@ -49,7 +49,7 @@ exports.redactGroupIds = (text) => { // redactSensitivePaths :: String -> String exports.redactSensitivePaths = (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)) { diff --git a/js/modules/string_to_array_buffer.js b/js/modules/string_to_array_buffer.js new file mode 100644 index 000000000..4c6fa700c --- /dev/null +++ b/js/modules/string_to_array_buffer.js @@ -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; +}; diff --git a/js/modules/types/attachment.js b/js/modules/types/attachment.js index 74c46bf12..cb257c212 100644 --- a/js/modules/types/attachment.js +++ b/js/modules/types/attachment.js @@ -1,8 +1,10 @@ +const isFunction = require('lodash/isFunction'); const isString = require('lodash/isString'); const MIME = require('./mime'); const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util'); const { autoOrientImage } = require('../auto_orient_image'); +const { migrateDataToFileSystem } = require('./attachment/migrate_data_to_file_system'); // // Incoming message attachment fields // { @@ -107,3 +109,62 @@ exports.removeSchemaVersion = (attachment) => { delete attachmentWithoutSchemaVersion.schemaVersion; 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); + }; +}; diff --git a/js/modules/types/attachment/migrate_data_to_file_system.js b/js/modules/types/attachment/migrate_data_to_file_system.js new file mode 100644 index 000000000..ed21cb2ab --- /dev/null +++ b/js/modules/types/attachment/migrate_data_to_file_system.js @@ -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; +}; diff --git a/js/modules/types/message.js b/js/modules/types/message.js index 71540a5ef..b4680818f 100644 --- a/js/modules/types/message.js +++ b/js/modules/types/message.js @@ -13,9 +13,12 @@ const PRIVATE = 'private'; // Version 0 // - Schema initialized // Version 1 -// - Attachments: Auto-orient JPEG attachments using EXIF `Orientation` data +// - Attachments: Auto-orient JPEG attachments using EXIF `Orientation` data. // 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; // 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 // incrementally, e.g. from version 0 / unknown -> 1, 1 --> 2, etc., similar to // how we do database migrations: -exports.CURRENT_SCHEMA_VERSION = 2; +exports.CURRENT_SCHEMA_VERSION = 3; // Public API @@ -73,18 +76,18 @@ exports.initializeSchemaVersion = (message) => { }; // Middleware -// type UpgradeStep = Message -> Promise Message +// type UpgradeStep = (Message, Context) -> Promise Message // SchemaVersion -> UpgradeStep -> UpgradeStep exports._withSchemaVersion = (schemaVersion, upgrade) => { if (!SchemaVersion.isValid(schemaVersion)) { - throw new TypeError('`schemaVersion` is invalid'); + throw new TypeError('"schemaVersion" is invalid'); } 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)) { console.log('Message._withSchemaVersion: Invalid input message:', message); return message; @@ -109,7 +112,7 @@ exports._withSchemaVersion = (schemaVersion, upgrade) => { let upgradedMessage; try { - upgradedMessage = await upgrade(message); + upgradedMessage = await upgrade(message, context); } catch (error) { console.log( 'Message._withSchemaVersion: error:', @@ -137,16 +140,14 @@ exports._withSchemaVersion = (schemaVersion, upgrade) => { // Public API // _mapAttachments :: (Attachment -> Promise Attachment) -> -// Message -> +// (Message, Context) -> // Promise Message -exports._mapAttachments = upgradeAttachment => async message => - Object.assign( - {}, - message, - { - attachments: await Promise.all(message.attachments.map(upgradeAttachment)), - } - ); +exports._mapAttachments = upgradeAttachment => async (message, context) => { + const upgradeWithContext = attachment => + upgradeAttachment(attachment, context); + const attachments = await Promise.all(message.attachments.map(upgradeWithContext)); + return Object.assign({}, message, { attachments }); +}; const toVersion0 = async message => exports.initializeSchemaVersion(message); @@ -159,7 +160,14 @@ const toVersion2 = exports._withSchemaVersion( 2, exports._mapAttachments(Attachment.replaceUnicodeOrderOverrides) ); +const toVersion3 = exports._withSchemaVersion( + 3, + exports._mapAttachments(Attachment.migrateDataToFileSystem) +); // UpgradeStep -exports.upgradeSchema = async message => - toVersion2(await toVersion1(await toVersion0(message))); +exports.upgradeSchema = async (message, { writeAttachmentData } = {}) => + toVersion3( + await toVersion2(await toVersion1(await toVersion0(message))), + { writeAttachmentData } + ); diff --git a/js/views/attachment_view.js b/js/views/attachment_view.js index 861ab218f..570d78d25 100644 --- a/js/views/attachment_view.js +++ b/js/views/attachment_view.js @@ -63,7 +63,7 @@ const VideoView = MediaView.extend({ tagName: 'video' }); // Blacklist common file types known to be unsupported in Chrome - const UnsupportedFileTypes = [ + const unsupportedFileTypes = [ 'audio/aiff', 'video/quicktime', ]; @@ -86,7 +86,7 @@ } }, events: { - click: 'onclick', + click: 'onClick', }, unload() { this.blob = null; @@ -109,7 +109,7 @@ default: return this.model.contentType.split('/')[1]; } }, - onclick() { + onClick() { if (this.isImage()) { this.lightBoxView = new Whisper.LightboxView({ model: this }); this.lightBoxView.render(); @@ -205,7 +205,7 @@ View = VideoView; } - if (!View || _.contains(UnsupportedFileTypes, this.model.contentType)) { + if (!View || _.contains(unsupportedFileTypes, this.model.contentType)) { this.update(); return this; } diff --git a/js/views/message_view.js b/js/views/message_view.js index 2811ead3a..866cba27a 100644 --- a/js/views/message_view.js +++ b/js/views/message_view.js @@ -1,10 +1,14 @@ -/* - * vim: ts=4:sw=4:expandtab - */ +/* eslint-disable */ + +/* global Whisper: false */ + (function () { 'use strict'; window.Whisper = window.Whisper || {}; + const { Attachment } = window.Signal.Types; + const { loadAttachmentData } = window.Signal.Migrations; + var URL_REGEX = /(^|[\s\n]|)((?:https?|ftp):\/\/[\-A-Z0-9\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFD+\u0026\u2019@#\/%?=()~_|!:,.;]*[\-A-Z0-9+\u0026@#\/%=~()_|])/gi; var ErrorIconView = Whisper.View.extend({ @@ -178,6 +182,9 @@ return this.model.id; }, initialize: function() { + // loadedAttachmentViews :: Promise (Array AttachmentView) | null + this.loadedAttachmentViews = null; + this.listenTo(this.model, 'change:errors', this.onErrorsChanged); this.listenTo(this.model, 'change:body', this.render); this.listenTo(this.model, 'change:delivered', this.renderDelivered); @@ -223,6 +230,7 @@ // Failsafe: if in the background, animation events don't fire setTimeout(this.remove.bind(this), 1000); }, + /* jshint ignore:start */ onUnload: function() { if (this.avatarView) { this.avatarView.remove(); @@ -239,18 +247,20 @@ if (this.timeStampView) { this.timeStampView.remove(); } - if (this.loadedAttachments && this.loadedAttachments.length) { - for (var i = 0, max = this.loadedAttachments.length; i < max; i += 1) { - var view = this.loadedAttachments[i]; - view.unload(); - } - } + + // NOTE: We have to do this in the background (`then` instead of `await`) + // as our tests rely on `onUnload` synchronously removing the view from + // 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: // this.timerView this.remove(); }, + /* jshint ignore:end */ onDestroy: function() { if (this.$el.hasClass('expired')) { return; @@ -375,7 +385,12 @@ this.renderErrors(); 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; }, @@ -394,51 +409,61 @@ }))(); this.$('.avatar').replaceWith(this.avatarView.render().$('.avatar')); }, - appendAttachmentView: function(view) { - // We check for a truthy 'updated' here to ensure that a race condition in a - // multi-fetch() scenario doesn't add an AttachmentView to the DOM before - // its 'update' event is triggered. - var parent = this.$('.attachments')[0]; - if (view.updated && parent !== view.el.parentNode) { - 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'); - } - }, - loadAttachments: function() { - this.loadedAttachments = this.loadedAttachments || []; - - // If we're called a second time, render() has replaced the DOM out from under - // 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) { - for (var i = 0, max = this.loadedAttachments.length; i < max; i += 1) { - var view = this.loadedAttachments[i]; - this.appendAttachmentView(view); - } - return; - } - - this.model.get('attachments').forEach(function(attachment) { - var view = new Whisper.AttachmentView({ - model: attachment, - timestamp: this.model.get('sent_at') - }); - this.loadedAttachments.push(view); - - this.listenTo(view, 'update', function() { - view.updated = true; - this.appendAttachmentView(view); - }); - - view.render(); - }.bind(this)); - } + /* eslint-enable */ + /* jshint ignore:start */ + loadAttachmentViews() { + if (this.loadedAttachmentViews !== null) { + return this.loadedAttachmentViews; + } + + const attachments = this.model.get('attachments') || []; + const loadedAttachmentViews = Promise.all(attachments.map(attachment => + new Promise(async (resolve) => { + const attachmentWithData = await loadAttachmentData(attachment); + const view = new Whisper.AttachmentView({ + model: attachmentWithData, + timestamp: this.model.get('sent_at'), + }); + + this.listenTo(view, 'update', () => { + // NOTE: Can we do without `updated` flag now that we use promises? + view.updated = true; + resolve(view); + }); + + view.render(); + }))); + + // Memoize attachment views to avoid double loading: + this.loadedAttachmentViews = loadedAttachmentViews; + + return loadedAttachmentViews; + }, + renderAttachmentViews(views) { + views.forEach(view => this.renderAttachmentView(view)); + }, + renderAttachmentView(view) { + if (!view.updated) { + throw new Error('Invariant violation:' + + ' Cannot render an attachment view that isn’t ready'); + } + + const parent = this.$('.attachments')[0]; + const isViewAlreadyChild = parent === view.el.parentNode; + if (isViewAlreadyChild) { + return; + } + + 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 */ }); - })(); diff --git a/libtextsecure/crypto.js b/libtextsecure/crypto.js index 24581401d..7687e2c54 100644 --- a/libtextsecure/crypto.js +++ b/libtextsecure/crypto.js @@ -87,6 +87,13 @@ }, 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) { throw new Error("Got invalid length attachment keys"); } diff --git a/libtextsecure/sendmessage.js b/libtextsecure/sendmessage.js index 34072020f..507029355 100644 --- a/libtextsecure/sendmessage.js +++ b/libtextsecure/sendmessage.js @@ -116,10 +116,21 @@ function MessageSender(url, username, password, cdn_url) { MessageSender.prototype = { constructor: MessageSender, + +// makeAttachmentPointer :: Attachment -> Promise AttachmentPointerProto makeAttachmentPointer: function(attachment) { if (typeof attachment !== 'object' || attachment == null) { 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(); proto.key = libsignal.crypto.getRandomBytes(64); diff --git a/main.js b/main.js index 874355082..9f2d87a3b 100644 --- a/main.js +++ b/main.js @@ -16,6 +16,7 @@ const { const packageJson = require('./package.json'); +const Attachments = require('./app/attachments'); const autoUpdate = require('./app/auto_update'); const createTrayIcon = require('./app/tray_icon'); const GlobalErrors = require('./js/modules/global_errors'); @@ -417,7 +418,7 @@ app.on('ready', () => { let loggingSetupError; logging.initialize().catch((error) => { loggingSetupError = error; - }).then(() => { + }).then(async () => { /* eslint-enable more/no-then */ logger = logging.getLogger(); logger.info('app ready'); @@ -431,6 +432,10 @@ app.on('ready', () => { locale = loadLocale({ appLocale, logger }); } + console.log('Ensure attachments directory exists'); + const userDataPath = app.getPath('userData'); + await Attachments.ensureDirectory(userDataPath); + ready = true; autoUpdate.initialize(getMainWindow, locale.messages); diff --git a/package.json b/package.json index c415331b6..88b0bf7d5 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "emoji-panel": "https://github.com/scottnonnenberg/emoji-panel.git#v0.5.5", "firstline": "^1.2.1", "form-data": "^2.3.2", + "fs-extra": "^5.0.0", "google-libphonenumber": "^3.0.7", "got": "^8.2.0", "lodash": "^4.17.4", @@ -77,6 +78,7 @@ "spellchecker": "^3.4.4", "testcheck": "^1.0.0-rc.2", "tmp": "^0.0.33", + "to-arraybuffer": "^1.0.1", "websocket": "^1.0.25" }, "devDependencies": { diff --git a/preload.js b/preload.js index cd701ad38..151856242 100644 --- a/preload.js +++ b/preload.js @@ -4,6 +4,13 @@ console.log('preload'); 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.config = require('url').parse(window.location.toString(), true).query; window.wrapDeferred = function(deferred) { @@ -103,19 +110,32 @@ window.autoOrientImage = autoOrientImage; // 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.Logs = require('./js/modules/logs'); window.Signal.OS = require('./js/modules/os'); window.Signal.Backup = require('./js/modules/backup'); 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.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.Message = require('./js/modules/types/message'); + window.Signal.Types.Message = Message; window.Signal.Types.MIME = require('./js/modules/types/mime'); window.Signal.Types.Settings = require('./js/modules/types/settings'); diff --git a/test/app/attachments_test.js b/test/app/attachments_test.js new file mode 100644 index 000000000..a186fa62b --- /dev/null +++ b/test/app/attachments_test.js @@ -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); + }); + }); +}); diff --git a/test/modules/types/attachment_test.js b/test/modules/types/attachment_test.js index bf4b8f6a4..5af44485a 100644 --- a/test/modules/types/attachment_test.js +++ b/test/modules/types/attachment_test.js @@ -3,6 +3,7 @@ require('mocha-testcheck').install(); const { assert } = require('chai'); const Attachment = require('../../../js/modules/types/attachment'); +const { stringToArrayBuffer } = require('../../../js/modules/string_to_array_buffer'); describe('Attachment', () => { describe('replaceUnicodeOrderOverrides', () => { @@ -101,4 +102,81 @@ describe('Attachment', () => { 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'); + }); + }); }); diff --git a/test/modules/types/message_test.js b/test/modules/types/message_test.js index e36538f88..a6afc0516 100644 --- a/test/modules/types/message_test.js +++ b/test/modules/types/message_test.js @@ -1,6 +1,7 @@ const { assert } = require('chai'); const Message = require('../../../js/modules/types/message'); +const { stringToArrayBuffer } = require('../../../js/modules/string_to_array_buffer'); describe('Message', () => { @@ -66,7 +67,7 @@ describe('Message', () => { const input = { attachments: [{ contentType: 'application/json', - data: null, + data: stringToArrayBuffer('It’s easy if you try'), fileName: 'test\u202Dfig.exe', size: 1111, }], @@ -75,14 +76,21 @@ describe('Message', () => { const expected = { attachments: [{ contentType: 'application/json', - data: null, + path: 'abc/abcdefg', fileName: 'test\uFFFDfig.exe', size: 1111, }], schemaVersion: Message.CURRENT_SCHEMA_VERSION, }; - const actual = await Message.upgradeSchema(input); + const expectedAttachmentData = stringToArrayBuffer('It’s 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); }); @@ -175,14 +183,14 @@ describe('Message', () => { const toVersionX = () => {}; assert.throws( () => Message._withSchemaVersion(toVersionX, 2), - '`schemaVersion` is invalid' + '"schemaVersion" is invalid' ); }); it('should require an upgrade function', () => { assert.throws( () => Message._withSchemaVersion(2, 3), - '`upgrade` must be a function' + '"upgrade" must be a function' ); }); diff --git a/yarn.lock b/yarn.lock index 23a52f066..1c798e11c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5427,6 +5427,10 @@ tmp@^0.0.33: dependencies: 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: version "2.0.0" resolved "https://registry.yarnpkg.com/to-double-quotes/-/to-double-quotes-2.0.0.tgz#aaf231d6fa948949f819301bbab4484d8588e4a7"