348 lines
9.3 KiB
JavaScript
348 lines
9.3 KiB
JavaScript
// Copyright 2018-2020 Signal Messenger, LLC
|
||
// SPDX-License-Identifier: AGPL-3.0-only
|
||
|
||
const is = require('@sindresorhus/is');
|
||
|
||
const {
|
||
arrayBufferToBlob,
|
||
blobToArrayBuffer,
|
||
dataURLToBlob,
|
||
} = require('blob-util');
|
||
const AttachmentTS = require('../../../ts/types/Attachment');
|
||
const GoogleChrome = require('../../../ts/util/GoogleChrome');
|
||
const MIME = require('../../../ts/types/MIME');
|
||
const { toLogFormat } = require('./errors');
|
||
const { autoOrientImage } = require('../auto_orient_image');
|
||
const {
|
||
migrateDataToFileSystem,
|
||
} = require('./attachment/migrate_data_to_file_system');
|
||
|
||
// // Incoming message attachment fields
|
||
// {
|
||
// id: string
|
||
// contentType: MIMEType
|
||
// data: ArrayBuffer
|
||
// digest: ArrayBuffer
|
||
// fileName?: string
|
||
// flags: null
|
||
// key: ArrayBuffer
|
||
// size: integer
|
||
// thumbnail: ArrayBuffer
|
||
// }
|
||
|
||
// // Outgoing message attachment fields
|
||
// {
|
||
// contentType: MIMEType
|
||
// data: ArrayBuffer
|
||
// fileName: string
|
||
// size: integer
|
||
// }
|
||
|
||
// Returns true if `rawAttachment` is a valid attachment based on our current schema.
|
||
// Over time, we can expand this definition to become more narrow, e.g. require certain
|
||
// fields, etc.
|
||
exports.isValid = rawAttachment => {
|
||
// NOTE: We cannot use `_.isPlainObject` because `rawAttachment` is
|
||
// deserialized by protobuf:
|
||
if (!rawAttachment) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
};
|
||
|
||
// Upgrade steps
|
||
// NOTE: This step strips all EXIF metadata from JPEG images as
|
||
// part of re-encoding the image:
|
||
exports.autoOrientJPEG = async attachment => {
|
||
if (!MIME.isJPEG(attachment.contentType)) {
|
||
return attachment;
|
||
}
|
||
|
||
// If we haven't downloaded the attachment yet, we won't have the data
|
||
if (!attachment.data) {
|
||
return attachment;
|
||
}
|
||
|
||
const dataBlob = await arrayBufferToBlob(
|
||
attachment.data,
|
||
attachment.contentType
|
||
);
|
||
const newDataBlob = await dataURLToBlob(await autoOrientImage(dataBlob));
|
||
const newDataArrayBuffer = await blobToArrayBuffer(newDataBlob);
|
||
|
||
// IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original
|
||
// image data. Ideally, we’d preserve the original image data for users who want to
|
||
// retain it but due to reports of data loss, we don’t want to overburden IndexedDB
|
||
// by potentially doubling stored image data.
|
||
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
|
||
const newAttachment = {
|
||
...attachment,
|
||
data: newDataArrayBuffer,
|
||
size: newDataArrayBuffer.byteLength,
|
||
};
|
||
|
||
// `digest` is no longer valid for auto-oriented image data, so we discard it:
|
||
delete newAttachment.digest;
|
||
|
||
return newAttachment;
|
||
};
|
||
|
||
const UNICODE_LEFT_TO_RIGHT_OVERRIDE = '\u202D';
|
||
const UNICODE_RIGHT_TO_LEFT_OVERRIDE = '\u202E';
|
||
const UNICODE_REPLACEMENT_CHARACTER = '\uFFFD';
|
||
const INVALID_CHARACTERS_PATTERN = new RegExp(
|
||
`[${UNICODE_LEFT_TO_RIGHT_OVERRIDE}${UNICODE_RIGHT_TO_LEFT_OVERRIDE}]`,
|
||
'g'
|
||
);
|
||
// NOTE: Expose synchronous version to do property-based testing using `testcheck`,
|
||
// which currently doesn’t support async testing:
|
||
// https://github.com/leebyron/testcheck-js/issues/45
|
||
exports._replaceUnicodeOrderOverridesSync = attachment => {
|
||
if (!is.string(attachment.fileName)) {
|
||
return attachment;
|
||
}
|
||
|
||
const normalizedFilename = attachment.fileName.replace(
|
||
INVALID_CHARACTERS_PATTERN,
|
||
UNICODE_REPLACEMENT_CHARACTER
|
||
);
|
||
const newAttachment = { ...attachment, fileName: normalizedFilename };
|
||
|
||
return newAttachment;
|
||
};
|
||
|
||
exports.replaceUnicodeOrderOverrides = async attachment =>
|
||
exports._replaceUnicodeOrderOverridesSync(attachment);
|
||
|
||
// \u202A-\u202E is LRE, RLE, PDF, LRO, RLO
|
||
// \u2066-\u2069 is LRI, RLI, FSI, PDI
|
||
// \u200E is LRM
|
||
// \u200F is RLM
|
||
// \u061C is ALM
|
||
const V2_UNWANTED_UNICODE = /[\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g;
|
||
|
||
exports.replaceUnicodeV2 = async attachment => {
|
||
if (!is.string(attachment.fileName)) {
|
||
return attachment;
|
||
}
|
||
|
||
const fileName = attachment.fileName.replace(
|
||
V2_UNWANTED_UNICODE,
|
||
UNICODE_REPLACEMENT_CHARACTER
|
||
);
|
||
|
||
return {
|
||
...attachment,
|
||
fileName,
|
||
};
|
||
};
|
||
|
||
exports.removeSchemaVersion = ({ attachment, logger }) => {
|
||
if (!exports.isValid(attachment)) {
|
||
logger.error(
|
||
'Attachment.removeSchemaVersion: Invalid input attachment:',
|
||
attachment
|
||
);
|
||
return attachment;
|
||
}
|
||
|
||
const attachmentWithoutSchemaVersion = { ...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 (!is.function(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 (!is.string(attachment.path)) {
|
||
throw new TypeError("'attachment.path' is required");
|
||
}
|
||
|
||
const data = await readAttachmentData(attachment.path);
|
||
return { ...attachment, data, size: data.byteLength };
|
||
};
|
||
};
|
||
|
||
// deleteData :: (RelativePath -> IO Unit)
|
||
// Attachment ->
|
||
// IO Unit
|
||
exports.deleteData = deleteOnDisk => {
|
||
if (!is.function(deleteOnDisk)) {
|
||
throw new TypeError('deleteData: deleteOnDisk must be a function');
|
||
}
|
||
|
||
return async attachment => {
|
||
if (!exports.isValid(attachment)) {
|
||
throw new TypeError('deleteData: attachment is not valid');
|
||
}
|
||
|
||
const { path, thumbnail, screenshot } = attachment;
|
||
if (is.string(path)) {
|
||
await deleteOnDisk(path);
|
||
}
|
||
|
||
if (thumbnail && is.string(thumbnail.path)) {
|
||
await deleteOnDisk(thumbnail.path);
|
||
}
|
||
|
||
if (screenshot && is.string(screenshot.path)) {
|
||
await deleteOnDisk(screenshot.path);
|
||
}
|
||
};
|
||
};
|
||
|
||
exports.isImage = AttachmentTS.isImage;
|
||
exports.isVideo = AttachmentTS.isVideo;
|
||
exports.isAudio = AttachmentTS.isAudio;
|
||
exports.isVoiceMessage = AttachmentTS.isVoiceMessage;
|
||
exports.save = AttachmentTS.save;
|
||
|
||
const THUMBNAIL_SIZE = 150;
|
||
const THUMBNAIL_CONTENT_TYPE = 'image/png';
|
||
|
||
exports.captureDimensionsAndScreenshot = async (
|
||
attachment,
|
||
{
|
||
writeNewAttachmentData,
|
||
getAbsoluteAttachmentPath,
|
||
makeObjectUrl,
|
||
revokeObjectUrl,
|
||
getImageDimensions,
|
||
makeImageThumbnail,
|
||
makeVideoScreenshot,
|
||
logger,
|
||
}
|
||
) => {
|
||
const { contentType } = attachment;
|
||
|
||
if (
|
||
!GoogleChrome.isImageTypeSupported(contentType) &&
|
||
!GoogleChrome.isVideoTypeSupported(contentType)
|
||
) {
|
||
return attachment;
|
||
}
|
||
|
||
// If the attachment hasn't been downloaded yet, we won't have a path
|
||
if (!attachment.path) {
|
||
return attachment;
|
||
}
|
||
|
||
const absolutePath = await getAbsoluteAttachmentPath(attachment.path);
|
||
|
||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||
try {
|
||
const { width, height } = await getImageDimensions({
|
||
objectUrl: absolutePath,
|
||
logger,
|
||
});
|
||
const thumbnailBuffer = await blobToArrayBuffer(
|
||
await makeImageThumbnail({
|
||
size: THUMBNAIL_SIZE,
|
||
objectUrl: absolutePath,
|
||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||
logger,
|
||
})
|
||
);
|
||
|
||
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
|
||
return {
|
||
...attachment,
|
||
width,
|
||
height,
|
||
thumbnail: {
|
||
path: thumbnailPath,
|
||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||
width: THUMBNAIL_SIZE,
|
||
height: THUMBNAIL_SIZE,
|
||
},
|
||
};
|
||
} catch (error) {
|
||
logger.error(
|
||
'captureDimensionsAndScreenshot:',
|
||
'error processing image; skipping screenshot generation',
|
||
toLogFormat(error)
|
||
);
|
||
return attachment;
|
||
}
|
||
}
|
||
|
||
let screenshotObjectUrl;
|
||
try {
|
||
const screenshotBuffer = await blobToArrayBuffer(
|
||
await makeVideoScreenshot({
|
||
objectUrl: absolutePath,
|
||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||
logger,
|
||
})
|
||
);
|
||
screenshotObjectUrl = makeObjectUrl(
|
||
screenshotBuffer,
|
||
THUMBNAIL_CONTENT_TYPE
|
||
);
|
||
const { width, height } = await getImageDimensions({
|
||
objectUrl: screenshotObjectUrl,
|
||
logger,
|
||
});
|
||
const screenshotPath = await writeNewAttachmentData(screenshotBuffer);
|
||
|
||
const thumbnailBuffer = await blobToArrayBuffer(
|
||
await makeImageThumbnail({
|
||
size: THUMBNAIL_SIZE,
|
||
objectUrl: screenshotObjectUrl,
|
||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||
logger,
|
||
})
|
||
);
|
||
|
||
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
|
||
|
||
return {
|
||
...attachment,
|
||
screenshot: {
|
||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||
path: screenshotPath,
|
||
width,
|
||
height,
|
||
},
|
||
thumbnail: {
|
||
path: thumbnailPath,
|
||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||
width: THUMBNAIL_SIZE,
|
||
height: THUMBNAIL_SIZE,
|
||
},
|
||
width,
|
||
height,
|
||
};
|
||
} catch (error) {
|
||
logger.error(
|
||
'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation',
|
||
toLogFormat(error)
|
||
);
|
||
return attachment;
|
||
} finally {
|
||
revokeObjectUrl(screenshotObjectUrl);
|
||
}
|
||
};
|