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:
commit
2e9f3bcf8d
21 changed files with 602 additions and 98 deletions
|
@ -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
99
app/attachments.js
Normal 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);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)) {
|
||||
|
|
11
js/modules/string_to_array_buffer.js
Normal file
11
js/modules/string_to_array_buffer.js
Normal 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;
|
||||
};
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
|
|
40
js/modules/types/attachment/migrate_data_to_file_system.js
Normal file
40
js/modules/types/attachment/migrate_data_to_file_system.js
Normal 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;
|
||||
};
|
|
@ -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 }
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 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 */
|
||||
});
|
||||
|
||||
})();
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
7
main.js
7
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);
|
||||
|
|
|
@ -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": {
|
||||
|
|
30
preload.js
30
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');
|
||||
|
||||
|
|
105
test/app/attachments_test.js
Normal file
105
test/app/attachments_test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue