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.
This commit is contained in:
Daniel Gasienica 2018-03-26 16:43:47 -04:00
commit 2e9f3bcf8d
21 changed files with 602 additions and 98 deletions

View file

@ -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

99
app/attachments.js Normal file
View file

@ -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);
};

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)) {

View file

@ -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;
};

View file

@ -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);
};
};

View file

@ -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;
};

View file

@ -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 }
);

View file

@ -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;
}

View file

@ -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]|<br\/?>)((?: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);
}
/* eslint-enable */
/* jshint ignore:start */
loadAttachmentViews() {
if (this.loadedAttachmentViews !== null) {
return this.loadedAttachmentViews;
}
this.trigger('beforeChangeHeight');
this.$('.attachments').append(view.el);
view.setElement(view.el);
this.trigger('afterChangeHeight');
}
},
loadAttachments: function() {
this.loadedAttachments = this.loadedAttachments || [];
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'),
});
// 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.listenTo(view, 'update', () => {
// NOTE: Can we do without `updated` flag now that we use promises?
view.updated = true;
resolve(view);
});
this.model.get('attachments').forEach(function(attachment) {
var view = new Whisper.AttachmentView({
model: attachment,
timestamp: this.model.get('sent_at')
});
this.loadedAttachments.push(view);
view.render();
})));
this.listenTo(view, 'update', function() {
view.updated = true;
this.appendAttachmentView(view);
});
// Memoize attachment views to avoid double loading:
this.loadedAttachmentViews = loadedAttachmentViews;
view.render();
}.bind(this));
}
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 isnt 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 */
});
})();

View file

@ -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");
}

View file

@ -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);

View file

@ -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);

View file

@ -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": {

View file

@ -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');

View file

@ -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);
});
});
});

View file

@ -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');
});
});
});

View file

@ -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('Its 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('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);
});
@ -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'
);
});

View file

@ -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"