// Copyright 2018-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only const is = require('@sindresorhus/is'); const { arrayBufferToBlob, blobToArrayBuffer } = 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 { scaleImageToLevel } = require('../../../ts/util/scaleImageToLevel'); 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, _, message) => { 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 xcodedDataBlob = await scaleImageToLevel( dataBlob, message.sendHQImages ); const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob); // 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 xcodedAttachment = { ...attachment, data: xcodedDataArrayBuffer, size: xcodedDataArrayBuffer.byteLength, }; // `digest` is no longer valid for auto-oriented image data, so we discard it: delete xcodedAttachment.digest; return xcodedAttachment; }; 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.isGIF = AttachmentTS.isGIF; exports.isAudio = AttachmentTS.isAudio; exports.isVoiceMessage = AttachmentTS.isVoiceMessage; exports.getUploadSizeLimitKb = AttachmentTS.getUploadSizeLimitKb; 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); } };