You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
session-desktop/js/modules/types/message.js

679 lines
21 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

const { isFunction, isObject, isString, omit } = require('lodash');
const Attachment = require('./attachment');
const Errors = require('./errors');
const SchemaVersion = require('./schema_version');
const {
initializeAttachmentMetadata,
} = require('../../../ts/types/message/initializeAttachmentMetadata');
const Contact = require('./contact');
const GROUP = 'group';
const PRIVATE = 'private';
// Schema version history
//
// Version 0
// - Schema initialized
// Version 1
// - Attachments: Auto-orient JPEG attachments using EXIF `Orientation` data.
// N.B. The process of auto-orient for JPEGs strips (loses) all existing
// EXIF metadata improving privacy, e.g. geolocation, camera make, etc.
// Version 2
// - Attachments: Sanitize Unicode order override characters.
// Version 3
// - Attachments: Write attachment data to disk and store relative path to it.
// Version 4
// - Quotes: Write thumbnail data to disk and store relative path to it.
// Version 5 (deprecated)
// - Attachments: Track number and kind of attachments for media gallery
// - `hasAttachments?: 1 | 0`
// - `hasVisualMediaAttachments?: 1 | undefined` (for media gallery Media view)
// - `hasFileAttachments?: 1 | undefined` (for media gallery Documents view)
// - IMPORTANT: Version 7 changes the classification of visual media and files.
// Therefore version 5 is considered deprecated. For an easier implementation,
// new files have the same classification in version 5 as in version 7.
// Version 6
// - Contact: Write contact avatar to disk, ensure contact data is well-formed
// Version 7 (supersedes attachment classification in version 5)
// - Attachments: Update classification for:
// - `hasVisualMediaAttachments`: Include all images and video regardless of
// whether Chromium can render it or not.
// - `hasFileAttachments`: Exclude voice messages.
// Version 8
// - Attachments: Capture video/image dimensions and thumbnails, as well as a
// full-size screenshot for video.
// Version 9
// - Attachments: Expand the set of unicode characters we filter out of
// attachment filenames
// Version 10
// - Preview: A new type of attachment can be included in a message.
const INITIAL_SCHEMA_VERSION = 0;
// Public API
exports.GROUP = GROUP;
exports.PRIVATE = PRIVATE;
// Placeholder until we have stronger preconditions:
exports.isValid = () => true;
// Schema
exports.initializeSchemaVersion = ({ message, logger }) => {
const isInitialized = SchemaVersion.isValid(message.schemaVersion) && message.schemaVersion >= 1;
if (isInitialized) {
return message;
}
const numAttachments = Array.isArray(message.attachments) ? message.attachments.length : 0;
const hasAttachments = numAttachments > 0;
if (!hasAttachments) {
return Object.assign({}, message, {
schemaVersion: INITIAL_SCHEMA_VERSION,
});
}
// All attachments should have the same schema version, so we just pick
// the first one:
const firstAttachment = message.attachments[0];
const inheritedSchemaVersion = SchemaVersion.isValid(firstAttachment.schemaVersion)
? firstAttachment.schemaVersion
: INITIAL_SCHEMA_VERSION;
const messageWithInitialSchema = Object.assign({}, message, {
schemaVersion: inheritedSchemaVersion,
attachments: message.attachments.map(attachment =>
Attachment.removeSchemaVersion({ attachment, logger })
),
});
return messageWithInitialSchema;
};
// Middleware
// type UpgradeStep = (Message, Context) -> Promise Message
// SchemaVersion -> UpgradeStep -> UpgradeStep
exports._withSchemaVersion = ({ schemaVersion, upgrade }) => {
if (!SchemaVersion.isValid(schemaVersion)) {
throw new TypeError('_withSchemaVersion: schemaVersion is invalid');
}
if (!isFunction(upgrade)) {
throw new TypeError('_withSchemaVersion: upgrade must be a function');
}
return async (message, context) => {
if (!context || !isObject(context.logger)) {
throw new TypeError('_withSchemaVersion: context must have logger object');
}
const { logger } = context;
if (!exports.isValid(message)) {
logger.error('Message._withSchemaVersion: Invalid input message:', message);
return message;
}
const isAlreadyUpgraded = message.schemaVersion >= schemaVersion;
if (isAlreadyUpgraded) {
return message;
}
const expectedVersion = schemaVersion - 1;
const hasExpectedVersion = message.schemaVersion === expectedVersion;
if (!hasExpectedVersion) {
logger.warn(
'WARNING: Message._withSchemaVersion: Unexpected version:',
`Expected message to have version ${expectedVersion},`,
`but got ${message.schemaVersion}.`
);
return message;
}
let upgradedMessage;
try {
upgradedMessage = await upgrade(message, context);
} catch (error) {
logger.error(
`Message._withSchemaVersion: error updating message ${message.id}:`,
Errors.toLogFormat(error)
);
return message;
}
if (!exports.isValid(upgradedMessage)) {
logger.error('Message._withSchemaVersion: Invalid upgraded message:', upgradedMessage);
return message;
}
return Object.assign({}, upgradedMessage, { schemaVersion });
};
};
// Public API
// _mapAttachments :: (Attachment -> Promise Attachment) ->
// (Message, Context) ->
// Promise Message
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 });
};
// Public API
// _mapContact :: (Contact -> Promise Contact) ->
// (Message, Context) ->
// Promise Message
exports._mapContact = upgradeContact => async (message, context) => {
const contextWithMessage = Object.assign({}, context, { message });
const upgradeWithContext = contact => upgradeContact(contact, contextWithMessage);
const contact = await Promise.all((message.contact || []).map(upgradeWithContext));
return Object.assign({}, message, { contact });
};
// _mapQuotedAttachments :: (QuotedAttachment -> Promise QuotedAttachment) ->
// (Message, Context) ->
// Promise Message
exports._mapQuotedAttachments = upgradeAttachment => async (message, context) => {
if (!message.quote) {
return message;
}
if (!context || !isObject(context.logger)) {
throw new Error('_mapQuotedAttachments: context must have logger object');
}
const upgradeWithContext = async attachment => {
const { thumbnail } = attachment;
if (!thumbnail) {
return attachment;
}
const upgradedThumbnail = await upgradeAttachment(thumbnail, context);
return Object.assign({}, attachment, {
thumbnail: upgradedThumbnail,
});
};
const quotedAttachments = (message.quote && message.quote.attachments) || [];
const attachments = await Promise.all(quotedAttachments.map(upgradeWithContext));
return Object.assign({}, message, {
quote: Object.assign({}, message.quote, {
attachments,
}),
});
};
// _mapPreviewAttachments :: (PreviewAttachment -> Promise PreviewAttachment) ->
// (Message, Context) ->
// Promise Message
exports._mapPreviewAttachments = upgradeAttachment => async (message, context) => {
if (!message.preview) {
return message;
}
if (!context || !isObject(context.logger)) {
throw new Error('_mapPreviewAttachments: context must have logger object');
}
const upgradeWithContext = async preview => {
const { image } = preview;
if (!image) {
return preview;
}
const upgradedImage = await upgradeAttachment(image, context);
return Object.assign({}, preview, {
image: upgradedImage,
});
};
const preview = await Promise.all((message.preview || []).map(upgradeWithContext));
return Object.assign({}, message, {
preview,
});
};
const toVersion0 = async (message, context) =>
exports.initializeSchemaVersion({ message, logger: context.logger });
const toVersion1 = exports._withSchemaVersion({
schemaVersion: 1,
upgrade: exports._mapAttachments(Attachment.autoOrientJPEG),
});
const toVersion2 = exports._withSchemaVersion({
schemaVersion: 2,
upgrade: exports._mapAttachments(Attachment.replaceUnicodeOrderOverrides),
});
const toVersion3 = exports._withSchemaVersion({
schemaVersion: 3,
upgrade: exports._mapAttachments(Attachment.migrateDataToFileSystem),
});
const toVersion4 = exports._withSchemaVersion({
schemaVersion: 4,
upgrade: exports._mapQuotedAttachments(Attachment.migrateDataToFileSystem),
});
const toVersion5 = exports._withSchemaVersion({
schemaVersion: 5,
upgrade: initializeAttachmentMetadata,
});
const toVersion6 = exports._withSchemaVersion({
schemaVersion: 6,
upgrade: exports._mapContact(Contact.parseAndWriteAvatar(Attachment.migrateDataToFileSystem)),
});
// IMPORTANT: Weve updated our definition of `initializeAttachmentMetadata`, so
// we need to run it again on existing items that have previously been incorrectly
// classified:
const toVersion7 = exports._withSchemaVersion({
schemaVersion: 7,
upgrade: initializeAttachmentMetadata,
});
const toVersion8 = exports._withSchemaVersion({
schemaVersion: 8,
upgrade: exports._mapAttachments(Attachment.captureDimensionsAndScreenshot),
});
const toVersion9 = exports._withSchemaVersion({
schemaVersion: 9,
upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
});
const toVersion10 = exports._withSchemaVersion({
schemaVersion: 10,
upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem),
});
const VERSIONS = [
toVersion0,
toVersion1,
toVersion2,
toVersion3,
toVersion4,
toVersion5,
toVersion6,
toVersion7,
toVersion8,
toVersion9,
toVersion10,
];
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
// We need dimensions and screenshots for images for proper display
exports.VERSION_NEEDED_FOR_DISPLAY = 9;
// UpgradeStep
exports.upgradeSchema = async (
rawMessage,
{
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
maxVersion = exports.CURRENT_SCHEMA_VERSION,
} = {}
) => {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError('context.writeNewAttachmentData is required');
}
if (!isFunction(getAbsoluteAttachmentPath)) {
throw new TypeError('context.getAbsoluteAttachmentPath is required');
}
if (!isFunction(makeObjectUrl)) {
throw new TypeError('context.makeObjectUrl is required');
}
if (!isFunction(revokeObjectUrl)) {
throw new TypeError('context.revokeObjectUrl is required');
}
if (!isFunction(getImageDimensions)) {
throw new TypeError('context.getImageDimensions is required');
}
if (!isFunction(makeImageThumbnail)) {
throw new TypeError('context.makeImageThumbnail is required');
}
if (!isFunction(makeVideoScreenshot)) {
throw new TypeError('context.makeVideoScreenshot is required');
}
if (!isObject(logger)) {
throw new TypeError('context.logger is required');
}
let message = rawMessage;
// eslint-disable-next-line no-restricted-syntax
for (let index = 0, max = VERSIONS.length; index < max; index += 1) {
if (maxVersion < index) {
break;
}
const currentVersion = VERSIONS[index];
// We really do want this intra-loop await because this is a chained async action,
// each step dependent on the previous
// eslint-disable-next-line no-await-in-loop
message = await currentVersion(message, {
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
});
}
return message;
};
// Runs on attachments outside of the schema upgrade process, since attachments are
// downloaded out of band.
exports.processNewAttachment = async (
attachment,
{
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
} = {}
) => {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError('context.writeNewAttachmentData is required');
}
if (!isFunction(getAbsoluteAttachmentPath)) {
throw new TypeError('context.getAbsoluteAttachmentPath is required');
}
if (!isFunction(makeObjectUrl)) {
throw new TypeError('context.makeObjectUrl is required');
}
if (!isFunction(revokeObjectUrl)) {
throw new TypeError('context.revokeObjectUrl is required');
}
if (!isFunction(getImageDimensions)) {
throw new TypeError('context.getImageDimensions is required');
}
if (!isFunction(makeImageThumbnail)) {
throw new TypeError('context.makeImageThumbnail is required');
}
if (!isFunction(makeVideoScreenshot)) {
throw new TypeError('context.makeVideoScreenshot is required');
}
if (!isObject(logger)) {
throw new TypeError('context.logger is required');
}
const rotatedAttachment = await Attachment.autoOrientJPEG(attachment);
const onDiskAttachment = await Attachment.migrateDataToFileSystem(rotatedAttachment, {
writeNewAttachmentData,
});
const finalAttachment = await Attachment.captureDimensionsAndScreenshot(onDiskAttachment, {
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
});
return finalAttachment;
};
exports.createAttachmentLoader = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError('createAttachmentLoader: loadAttachmentData is required');
}
return async message =>
Object.assign({}, message, {
attachments: await Promise.all(message.attachments.map(loadAttachmentData)),
});
};
exports.loadQuoteData = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError('loadQuoteData: loadAttachmentData is required');
}
return async quote => {
if (!quote) {
return null;
}
return {
...quote,
attachments: await Promise.all(
(quote.attachments || []).map(async attachment => {
const { thumbnail } = attachment;
if (!thumbnail || !thumbnail.path) {
return attachment;
}
return {
...attachment,
thumbnail: await loadAttachmentData(thumbnail),
};
})
),
};
};
};
exports.loadPreviewData = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError('loadPreviewData: loadAttachmentData is required');
}
return async preview => {
if (!preview || !preview.length) {
return [];
}
return Promise.all(
preview.map(async item => {
if (!item.image) {
return item;
}
return {
...item,
image: await loadAttachmentData(item.image),
};
})
);
};
};
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
if (!isFunction(deleteAttachmentData)) {
throw new TypeError('deleteAllExternalFiles: deleteAttachmentData must be a function');
}
if (!isFunction(deleteOnDisk)) {
throw new TypeError('deleteAllExternalFiles: deleteOnDisk must be a function');
}
return async message => {
const { attachments, quote, contact, preview } = message;
if (attachments && attachments.length) {
await Promise.all(attachments.map(deleteAttachmentData));
}
if (quote && quote.attachments && quote.attachments.length) {
await Promise.all(
quote.attachments.map(async attachment => {
const { thumbnail } = attachment;
// To prevent spoofing, we copy the original image from the quoted message.
// If so, it will have a 'copied' field. We don't want to delete it if it has
// that field set to true.
if (thumbnail && thumbnail.path && !thumbnail.copied) {
await deleteOnDisk(thumbnail.path);
}
})
);
}
if (contact && contact.length) {
await Promise.all(
contact.map(async item => {
const { avatar } = item;
if (avatar && avatar.avatar && avatar.avatar.path) {
await deleteOnDisk(avatar.avatar.path);
}
})
);
}
if (preview && preview.length) {
await Promise.all(
preview.map(async item => {
const { image } = item;
if (image && image.path) {
await deleteOnDisk(image.path);
}
})
);
}
};
};
// createAttachmentDataWriter :: (RelativePath -> IO Unit)
// Message ->
// IO (Promise Message)
exports.createAttachmentDataWriter = ({ writeExistingAttachmentData, logger }) => {
if (!isFunction(writeExistingAttachmentData)) {
throw new TypeError(
'createAttachmentDataWriter: writeExistingAttachmentData must be a function'
);
}
if (!isObject(logger)) {
throw new TypeError('createAttachmentDataWriter: logger must be an object');
}
return async rawMessage => {
if (!exports.isValid(rawMessage)) {
throw new TypeError("'rawMessage' is not valid");
}
const message = exports.initializeSchemaVersion({
message: rawMessage,
logger,
});
const { attachments, quote, contact, preview } = message;
const hasFilesToWrite =
(quote && quote.attachments && quote.attachments.length > 0) ||
(attachments && attachments.length > 0) ||
(contact && contact.length > 0) ||
(preview && preview.length > 0);
if (!hasFilesToWrite) {
return message;
}
const lastVersionWithAttachmentDataInMemory = 2;
const willAttachmentsGoToFileSystemOnUpgrade =
message.schemaVersion <= lastVersionWithAttachmentDataInMemory;
if (willAttachmentsGoToFileSystemOnUpgrade) {
return message;
}
(attachments || []).forEach(attachment => {
if (!Attachment.hasData(attachment)) {
throw new TypeError("'attachment.data' is required during message import");
}
if (!isString(attachment.path)) {
throw new TypeError("'attachment.path' is required during message import");
}
});
const writeThumbnails = exports._mapQuotedAttachments(async thumbnail => {
const { data, path } = thumbnail;
// we want to be bulletproof to thumbnails without data
if (!data || !path) {
logger.warn(
'Thumbnail had neither data nor path.',
'id:',
message.id,
'source:',
message.source
);
return thumbnail;
}
await writeExistingAttachmentData(thumbnail);
return omit(thumbnail, ['data']);
});
const writeContactAvatar = async messageContact => {
const { avatar } = messageContact;
if (avatar && !avatar.avatar) {
return omit(messageContact, ['avatar']);
}
await writeExistingAttachmentData(avatar.avatar);
return Object.assign({}, messageContact, {
avatar: Object.assign({}, avatar, {
avatar: omit(avatar.avatar, ['data']),
}),
});
};
const writePreviewImage = async item => {
const { image } = item;
if (!image) {
return omit(item, ['image']);
}
await writeExistingAttachmentData(image);
return Object.assign({}, item, {
image: omit(image, ['data']),
});
};
const messageWithoutAttachmentData = Object.assign(
{},
await writeThumbnails(message, { logger }),
{
contact: await Promise.all((contact || []).map(writeContactAvatar)),
preview: await Promise.all((preview || []).map(writePreviewImage)),
attachments: await Promise.all(
(attachments || []).map(async attachment => {
await writeExistingAttachmentData(attachment);
if (attachment.screenshot && attachment.screenshot.data) {
await writeExistingAttachmentData(attachment.screenshot);
}
if (attachment.thumbnail && attachment.thumbnail.data) {
await writeExistingAttachmentData(attachment.thumbnail);
}
return {
...omit(attachment, ['data']),
...(attachment.thumbnail
? { thumbnail: omit(attachment.thumbnail, ['data']) }
: null),
...(attachment.screenshot
? { screenshot: omit(attachment.screenshot, ['data']) }
: null),
};
})
),
}
);
return messageWithoutAttachmentData;
};
};