Show lightbox for GIFs

This commit is contained in:
Fedor Indutny 2021-07-14 16:39:52 -07:00 committed by GitHub
parent 62ab66c1c8
commit c3bdf3d411
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 790 additions and 815 deletions

View file

@ -3,7 +3,7 @@
import { app, dialog, clipboard } from 'electron';
import * as Errors from '../js/modules/types/errors';
import * as Errors from '../ts/types/errors';
import { redactAll } from '../ts/util/privacy';
import { LocaleMessagesType } from '../ts/types/I18N';
import { reallyJsonStringify } from '../ts/util/reallyJsonStringify';

View file

@ -131,11 +131,11 @@ const conversationsSelectors = require('../../ts/state/selectors/conversations')
const searchSelectors = require('../../ts/state/selectors/search');
// Types
const AttachmentType = require('./types/attachment');
const AttachmentType = require('../../ts/types/Attachment');
const VisualAttachment = require('./types/visual_attachment');
const Contact = require('../../ts/types/Contact');
const Conversation = require('./types/conversation');
const Errors = require('./types/errors');
const Errors = require('../../ts/types/errors');
const MediaGalleryMessage = require('../../ts/components/conversation/media-gallery/types/Message');
const MessageType = require('./types/message');
const MIME = require('../../ts/types/MIME');

View file

@ -1,347 +0,0 @@
// 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 { 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 (!AttachmentTS.canBeTranscoded(attachment)) {
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 ? message.sendHQImages : false
);
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
// IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original
// image data. Ideally, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont 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 doesnt 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);
}
};

View file

@ -1,40 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const { isArrayBuffer, isFunction, isUndefined, omit } = require('lodash');
// type Context :: {
// writeNewAttachmentData :: ArrayBuffer -> Promise (IO Path)
// }
//
// migrateDataToFileSystem :: Attachment ->
// Context ->
// Promise Attachment
exports.migrateDataToFileSystem = async (
attachment,
{ writeNewAttachmentData } = {}
) => {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError("'writeNewAttachmentData' must be a function");
}
const { data } = attachment;
const hasData = !isUndefined(data);
const shouldSkipSchemaUpgrade = !hasData;
if (shouldSkipSchemaUpgrade) {
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 writeNewAttachmentData(data);
const attachmentWithoutData = omit({ ...attachment, path }, ['data']);
return attachmentWithoutData;
};

View file

@ -3,7 +3,7 @@
const { omit, compact, map } = require('lodash');
const { toLogFormat } = require('./errors');
const { toLogFormat } = require('../../../ts/types/errors');
const { SignalService } = require('../../../ts/protobuf');
const { parse: parsePhoneNumber } = require('../../../ts/types/PhoneNumber');

View file

@ -1,4 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function toLogFormat(error: any): string;

View file

@ -1,15 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
// toLogFormat :: Error -> String
exports.toLogFormat = error => {
if (!error) {
return error;
}
if (error && error.stack) {
return error.stack;
}
return error.toString();
};

View file

@ -4,8 +4,8 @@
const { isFunction, isObject, isString, omit } = require('lodash');
const Contact = require('./contact');
const Attachment = require('./attachment');
const Errors = require('./errors');
const Attachment = require('../../../ts/types/Attachment');
const Errors = require('../../../ts/types/errors');
const SchemaVersion = require('./schema_version');
const {
initializeAttachmentMetadata,

View file

@ -5,7 +5,7 @@
const loadImage = require('blueimp-load-image');
const { blobToArrayBuffer } = require('blob-util');
const { toLogFormat } = require('./errors');
const { toLogFormat } = require('../../../ts/types/errors');
const {
arrayBufferToObjectURL,
} = require('../../../ts/util/arrayBufferToObjectURL');

View file

@ -78,7 +78,7 @@
"axe-core": "4.1.4",
"backbone": "1.4.0",
"better-sqlite3": "https://github.com/signalapp/better-sqlite3#2fa02d2484e9f9a10df5ac7ea4617fb2dff30006",
"blob-util": "1.3.0",
"blob-util": "2.0.2",
"blueimp-load-image": "5.14.0",
"blurhash": "1.1.3",
"classnames": "2.2.5",

View file

@ -4084,6 +4084,10 @@ button.module-conversation-details__action-button {
line-height: 14px;
user-select: none;
}
video {
cursor: pointer;
}
}
// Only if it's a sticker do we put the outline inside it

View file

@ -1,234 +0,0 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
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', () => {
it('should sanitize left-to-right order override character', async () => {
const input = {
contentType: 'image/jpeg',
fileName: 'test\u202Dfig.exe',
size: 1111,
};
const expected = {
contentType: 'image/jpeg',
fileName: 'test\uFFFDfig.exe',
size: 1111,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
it('should sanitize right-to-left order override character', async () => {
const input = {
contentType: 'image/jpeg',
fileName: 'test\u202Efig.exe',
size: 1111,
};
const expected = {
contentType: 'image/jpeg',
fileName: 'test\uFFFDfig.exe',
size: 1111,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
it('should sanitize multiple override characters', async () => {
const input = {
contentType: 'image/jpeg',
fileName: 'test\u202e\u202dlol\u202efig.exe',
size: 1111,
};
const expected = {
contentType: 'image/jpeg',
fileName: 'test\uFFFD\uFFFDlol\uFFFDfig.exe',
size: 1111,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
const hasNoUnicodeOrderOverrides = value =>
!value.includes('\u202D') && !value.includes('\u202E');
check.it(
'should ignore non-order-override characters',
gen.string.suchThat(hasNoUnicodeOrderOverrides),
fileName => {
const input = {
contentType: 'image/jpeg',
fileName,
size: 1111,
};
const actual = Attachment._replaceUnicodeOrderOverridesSync(input);
assert.deepEqual(actual, input);
}
);
});
describe('replaceUnicodeV2', () => {
it('should remove all bad characters', async () => {
const input = {
size: 1111,
fileName:
'file\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u200E\u200F\u061C.jpeg',
};
const expected = {
fileName:
'file\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD.jpeg',
size: 1111,
};
const actual = await Attachment.replaceUnicodeV2(input);
assert.deepEqual(actual, expected);
});
it('should should leave normal filename alone', async () => {
const input = {
fileName: 'normal.jpeg',
size: 1111,
};
const expected = {
fileName: 'normal.jpeg',
size: 1111,
};
const actual = await Attachment.replaceUnicodeV2(input);
assert.deepEqual(actual, expected);
});
it('should handle missing fileName', async () => {
const input = {
size: 1111,
};
const expected = {
size: 1111,
};
const actual = await Attachment.replaceUnicodeV2(input);
assert.deepEqual(actual, expected);
});
});
describe('removeSchemaVersion', () => {
it('should remove existing schema version', () => {
const input = {
contentType: 'image/jpeg',
fileName: 'foo.jpg',
size: 1111,
schemaVersion: 1,
};
const expected = {
contentType: 'image/jpeg',
fileName: 'foo.jpg',
size: 1111,
};
const actual = Attachment.removeSchemaVersion({
attachment: input,
logger: {
error: () => null,
},
});
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 writeNewAttachmentData = async attachmentData => {
assert.deepEqual(attachmentData, expectedAttachmentData);
return 'abc/abcdefgh123456789';
};
const actual = await Attachment.migrateDataToFileSystem(input, {
writeNewAttachmentData,
logger: {
warn: () => null,
},
});
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 writeNewAttachmentData = async () => 'abc/abcdefgh123456789';
const actual = await Attachment.migrateDataToFileSystem(input, {
writeNewAttachmentData,
logger: {
warn: () => null,
},
});
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 writeNewAttachmentData = async () => 'abc/abcdefgh123456789';
try {
await Attachment.migrateDataToFileSystem(input, {
writeNewAttachmentData,
logger: {
warn: () => null,
},
});
} catch (error) {
assert.strictEqual(
error.message,
'Expected `attachment.data` to be an array buffer; got: number'
);
return;
}
assert.fail('Unreachable');
});
});
});

View file

@ -31,6 +31,7 @@ export type Props = {
objectURL: string;
caption?: string;
isViewOnce: boolean;
loop?: boolean;
onNext?: () => void;
onPrevious?: () => void;
onSave?: () => void;
@ -300,6 +301,7 @@ export class Lightbox extends React.Component<Props, State> {
contentType,
i18n,
isViewOnce,
loop = false,
objectURL,
onNext,
onPrevious,
@ -320,7 +322,13 @@ export class Lightbox extends React.Component<Props, State> {
<div style={styles.controlsOffsetPlaceholder} />
<div style={styles.objectContainer}>
{!is.undefined(contentType)
? this.renderObject({ objectURL, contentType, i18n, isViewOnce })
? this.renderObject({
objectURL,
contentType,
i18n,
isViewOnce,
loop,
})
: null}
{caption ? <div style={styles.caption}>{caption}</div> : null}
</div>
@ -363,11 +371,13 @@ export class Lightbox extends React.Component<Props, State> {
contentType,
i18n,
isViewOnce,
loop,
}: {
objectURL: string;
contentType: MIME.MIMEType;
i18n: LocalizerType;
isViewOnce: boolean;
loop: boolean;
}) => {
const isImageTypeSupported = GoogleChrome.isImageTypeSupported(contentType);
if (isImageTypeSupported) {
@ -392,8 +402,8 @@ export class Lightbox extends React.Component<Props, State> {
return (
<video
ref={this.videoRef}
loop={isViewOnce}
controls={!isViewOnce}
loop={loop || isViewOnce}
controls={!loop && !isViewOnce}
style={styles.object}
key={objectURL}
>

View file

@ -29,6 +29,7 @@ export type Props = {
readonly reducedMotion?: boolean;
onError(): void;
showVisualAttachment(): void;
kickOffAttachmentDownload(): void;
};
@ -48,6 +49,7 @@ export const GIF: React.FC<Props> = props => {
),
onError,
showVisualAttachment,
kickOffAttachmentDownload,
} = props;
@ -191,6 +193,12 @@ export const GIF: React.FC<Props> = props => {
onTimeUpdate={onTimeUpdate}
onEnded={onEnded}
onError={onError}
onClick={(event: React.MouseEvent): void => {
event.preventDefault();
event.stopPropagation();
showVisualAttachment();
}}
className="module-image--gif__video"
autoPlay
playsInline

View file

@ -716,6 +716,12 @@ export class Message extends React.Component<Props, State> {
tabIndex={0}
reducedMotion={reducedMotion}
onError={this.handleImageError}
showVisualAttachment={() => {
showVisualAttachment({
attachment: firstAttachment,
messageId: id,
});
}}
kickOffAttachmentDownload={() => {
kickOffAttachmentDownload({
attachment: firstAttachment,

View file

@ -1,11 +1,11 @@
// Copyright 2018-2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Attachment } from '../../../../types/Attachment';
import { AttachmentType } from '../../../../types/Attachment';
export type Message = {
id: string;
attachments: Array<Attachment>;
attachments: Array<AttachmentType>;
// Assuming this is for the API
// eslint-disable-next-line camelcase
received_at: number;

View file

@ -1,11 +1,9 @@
// Copyright 2018-2020 Signal Messenger, LLC
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const Path = require('path');
const { assert } = require('chai');
const Errors = require('../../../js/modules/types/errors');
import * as Path from 'path';
import { assert } from 'chai';
import * as Errors from '../../types/errors';
const APP_ROOT_PATH = Path.join(__dirname, '..', '..', '..');
@ -26,9 +24,9 @@ describe('Errors', () => {
it('should return error string representation if stack is missing', () => {
const error = new Error('boom');
error.stack = null;
error.stack = undefined;
assert.typeOf(error, 'Error');
assert.isNull(error.stack);
assert.isUndefined(error.stack);
const formattedError = Errors.toLogFormat(error);
assert.strictEqual(formattedError, 'Error: boom');
@ -37,7 +35,7 @@ describe('Errors', () => {
[0, false, null, undefined].forEach(value => {
it(`should return \`${value}\` argument`, () => {
const formattedNonError = Errors.toLogFormat(value);
assert.strictEqual(formattedNonError, value);
assert.strictEqual(formattedNonError, String(value));
});
});
});

View file

@ -7,6 +7,7 @@ import * as Attachment from '../../types/Attachment';
import * as MIME from '../../types/MIME';
import { SignalService } from '../../protobuf';
import { stringToArrayBuffer } from '../../../js/modules/string_to_array_buffer';
import * as logger from '../../logging/log';
describe('Attachment', () => {
describe('getUploadSizeLimitKb', () => {
@ -36,7 +37,7 @@ describe('Attachment', () => {
describe('getFileExtension', () => {
it('should return file extension from content type', () => {
const input: Attachment.Attachment = {
const input: Attachment.AttachmentType = {
data: stringToArrayBuffer('foo'),
contentType: MIME.IMAGE_GIF,
};
@ -44,7 +45,7 @@ describe('Attachment', () => {
});
it('should return file extension for QuickTime videos', () => {
const input: Attachment.Attachment = {
const input: Attachment.AttachmentType = {
data: stringToArrayBuffer('foo'),
contentType: MIME.VIDEO_QUICKTIME,
};
@ -55,7 +56,7 @@ describe('Attachment', () => {
describe('getSuggestedFilename', () => {
context('for attachment with filename', () => {
it('should return existing filename if present', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'funny-cat.mov',
data: stringToArrayBuffer('foo'),
contentType: MIME.VIDEO_QUICKTIME,
@ -67,7 +68,7 @@ describe('Attachment', () => {
});
context('for attachment without filename', () => {
it('should generate a filename based on timestamp', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
data: stringToArrayBuffer('foo'),
contentType: MIME.VIDEO_QUICKTIME,
};
@ -82,7 +83,7 @@ describe('Attachment', () => {
});
context('for attachment with index', () => {
it('should generate a filename based on timestamp', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
data: stringToArrayBuffer('foo'),
contentType: MIME.VIDEO_QUICKTIME,
};
@ -100,7 +101,7 @@ describe('Attachment', () => {
describe('isVisualMedia', () => {
it('should return true for images', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'meme.gif',
data: stringToArrayBuffer('gif'),
contentType: MIME.IMAGE_GIF,
@ -109,7 +110,7 @@ describe('Attachment', () => {
});
it('should return true for videos', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'meme.mp4',
data: stringToArrayBuffer('mp4'),
contentType: MIME.VIDEO_MP4,
@ -118,7 +119,7 @@ describe('Attachment', () => {
});
it('should return false for voice message attachment', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'Voice Message.aac',
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('voice message'),
@ -128,7 +129,7 @@ describe('Attachment', () => {
});
it('should return false for other attachments', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'foo.json',
data: stringToArrayBuffer('{"foo": "bar"}'),
contentType: MIME.APPLICATION_JSON,
@ -139,7 +140,7 @@ describe('Attachment', () => {
describe('isFile', () => {
it('should return true for JSON', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'foo.json',
data: stringToArrayBuffer('{"foo": "bar"}'),
contentType: MIME.APPLICATION_JSON,
@ -148,7 +149,7 @@ describe('Attachment', () => {
});
it('should return false for images', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'meme.gif',
data: stringToArrayBuffer('gif'),
contentType: MIME.IMAGE_GIF,
@ -157,7 +158,7 @@ describe('Attachment', () => {
});
it('should return false for videos', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'meme.mp4',
data: stringToArrayBuffer('mp4'),
contentType: MIME.VIDEO_MP4,
@ -166,7 +167,7 @@ describe('Attachment', () => {
});
it('should return false for voice message attachment', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'Voice Message.aac',
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('voice message'),
@ -178,7 +179,7 @@ describe('Attachment', () => {
describe('isVoiceMessage', () => {
it('should return true for voice message attachment', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'Voice Message.aac',
flags: SignalService.AttachmentPointer.Flags.VOICE_MESSAGE,
data: stringToArrayBuffer('voice message'),
@ -188,7 +189,7 @@ describe('Attachment', () => {
});
it('should return true for legacy Android voice message attachment', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
data: stringToArrayBuffer('voice message'),
contentType: MIME.AUDIO_MP3,
};
@ -196,7 +197,7 @@ describe('Attachment', () => {
});
it('should return false for other attachments', () => {
const attachment: Attachment.Attachment = {
const attachment: Attachment.AttachmentType = {
fileName: 'foo.gif',
data: stringToArrayBuffer('foo'),
contentType: MIME.IMAGE_GIF,
@ -204,4 +205,222 @@ describe('Attachment', () => {
assert.isFalse(Attachment.isVoiceMessage(attachment));
});
});
describe('replaceUnicodeOrderOverrides', () => {
it('should sanitize left-to-right order override character', async () => {
const input = {
contentType: MIME.IMAGE_JPEG,
fileName: 'test\u202Dfig.exe',
size: 1111,
};
const expected = {
contentType: MIME.IMAGE_JPEG,
fileName: 'test\uFFFDfig.exe',
size: 1111,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
it('should sanitize right-to-left order override character', async () => {
const input = {
contentType: MIME.IMAGE_JPEG,
fileName: 'test\u202Efig.exe',
size: 1111,
};
const expected = {
contentType: MIME.IMAGE_JPEG,
fileName: 'test\uFFFDfig.exe',
size: 1111,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
it('should sanitize multiple override characters', async () => {
const input = {
contentType: MIME.IMAGE_JPEG,
fileName: 'test\u202e\u202dlol\u202efig.exe',
size: 1111,
};
const expected = {
contentType: MIME.IMAGE_JPEG,
fileName: 'test\uFFFD\uFFFDlol\uFFFDfig.exe',
size: 1111,
};
const actual = await Attachment.replaceUnicodeOrderOverrides(input);
assert.deepEqual(actual, expected);
});
it('should ignore non-order-override characters', () => {
const input = {
contentType: MIME.IMAGE_JPEG,
fileName: 'abc',
size: 1111,
};
const actual = Attachment._replaceUnicodeOrderOverridesSync(input);
assert.deepEqual(actual, input);
});
it('should replace order-override characters', () => {
const input = {
contentType: MIME.IMAGE_JPEG,
fileName: 'abc\u202D\u202E',
size: 1111,
};
const actual = Attachment._replaceUnicodeOrderOverridesSync(input);
assert.deepEqual(actual, {
contentType: MIME.IMAGE_JPEG,
fileName: 'abc\uFFFD\uFFFD',
size: 1111,
});
});
});
describe('replaceUnicodeV2', () => {
it('should remove all bad characters', async () => {
const input = {
size: 1111,
contentType: MIME.IMAGE_JPEG,
fileName:
'file\u202A\u202B\u202C\u202D\u202E\u2066\u2067\u2068\u2069\u200E\u200F\u061C.jpeg',
};
const expected = {
fileName:
'file\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD\uFFFD.jpeg',
contentType: MIME.IMAGE_JPEG,
size: 1111,
};
const actual = await Attachment.replaceUnicodeV2(input);
assert.deepEqual(actual, expected);
});
it('should should leave normal filename alone', async () => {
const input = {
fileName: 'normal.jpeg',
contentType: MIME.IMAGE_JPEG,
size: 1111,
};
const expected = {
fileName: 'normal.jpeg',
contentType: MIME.IMAGE_JPEG,
size: 1111,
};
const actual = await Attachment.replaceUnicodeV2(input);
assert.deepEqual(actual, expected);
});
it('should handle missing fileName', async () => {
const input = {
size: 1111,
contentType: MIME.IMAGE_JPEG,
};
const expected = {
size: 1111,
contentType: MIME.IMAGE_JPEG,
};
const actual = await Attachment.replaceUnicodeV2(input);
assert.deepEqual(actual, expected);
});
});
describe('removeSchemaVersion', () => {
it('should remove existing schema version', () => {
const input = {
contentType: MIME.IMAGE_JPEG,
fileName: 'foo.jpg',
size: 1111,
schemaVersion: 1,
};
const expected = {
contentType: MIME.IMAGE_JPEG,
fileName: 'foo.jpg',
size: 1111,
};
const actual = Attachment.removeSchemaVersion({
attachment: input,
logger,
});
assert.deepEqual(actual, expected);
});
});
describe('migrateDataToFileSystem', () => {
it('should write data to disk and store relative path to it', async () => {
const input = {
contentType: MIME.IMAGE_JPEG,
data: stringToArrayBuffer('Above us only sky'),
fileName: 'foo.jpg',
size: 1111,
};
const expected = {
contentType: MIME.IMAGE_JPEG,
path: 'abc/abcdefgh123456789',
fileName: 'foo.jpg',
size: 1111,
};
const expectedAttachmentData = stringToArrayBuffer('Above us only sky');
const writeNewAttachmentData = async (attachmentData: ArrayBuffer) => {
assert.deepEqual(attachmentData, expectedAttachmentData);
return 'abc/abcdefgh123456789';
};
const actual = await Attachment.migrateDataToFileSystem(input, {
writeNewAttachmentData,
});
assert.deepEqual(actual, expected);
});
it('should skip over (invalid) attachments without data', async () => {
const input = {
contentType: MIME.IMAGE_JPEG,
fileName: 'foo.jpg',
size: 1111,
};
const expected = {
contentType: MIME.IMAGE_JPEG,
fileName: 'foo.jpg',
size: 1111,
};
const writeNewAttachmentData = async () => 'abc/abcdefgh123456789';
const actual = await Attachment.migrateDataToFileSystem(input, {
writeNewAttachmentData,
});
assert.deepEqual(actual, expected);
});
it('should throw error if data is not valid', async () => {
const input = {
contentType: MIME.IMAGE_JPEG,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: 123 as any,
fileName: 'foo.jpg',
size: 1111,
};
const writeNewAttachmentData = async () => 'abc/abcdefgh123456789';
await assert.isRejected(
Attachment.migrateDataToFileSystem(input, {
writeNewAttachmentData,
}),
'Expected `attachment.data` to be an array buffer; got: number'
);
});
});
});

7
ts/textsecure.d.ts vendored
View file

@ -37,13 +37,6 @@ export type UnprocessedType = {
export { StorageServiceCallOptionsType, StorageServiceCredentials };
export type DownloadAttachmentType = Omit<
ProcessedAttachment,
'digest' | 'key'
> & {
data: ArrayBuffer;
};
export type TextSecureType = {
createTaskWithTimeout: (
task: () => Promise<any> | any,

View file

@ -62,11 +62,13 @@ import * as Bytes from '../Bytes';
import Crypto from './Crypto';
import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto';
import { ContactBuffer, GroupBuffer } from './ContactsParser';
import { DownloadedAttachmentType } from '../types/Attachment';
import * as MIME from '../types/MIME';
import { SocketStatus } from '../types/SocketStatus';
import { SignalService as Proto } from '../protobuf';
import { DownloadAttachmentType, UnprocessedType } from '../textsecure.d';
import { UnprocessedType } from '../textsecure.d';
import {
ProcessedAttachment,
ProcessedDataMessage,
@ -2441,7 +2443,7 @@ class MessageReceiverInner extends EventTarget {
async downloadAttachment(
attachment: ProcessedAttachment
): Promise<DownloadAttachmentType> {
): Promise<DownloadedAttachmentType> {
const cdnId = attachment.cdnId || attachment.cdnKey;
const { cdnNumber } = attachment;
@ -2454,7 +2456,7 @@ class MessageReceiverInner extends EventTarget {
cdnId,
dropNull(cdnNumber)
);
const { key, digest, size } = attachment;
const { key, digest, size, contentType } = attachment;
if (!digest) {
throw new Error('Failure: Ask sender to update Signal and resend.');
@ -2479,13 +2481,17 @@ class MessageReceiverInner extends EventTarget {
return {
...omit(attachment, 'digest', 'key'),
contentType: contentType
? MIME.fromString(contentType)
: MIME.APPLICATION_OCTET_STREAM,
data,
};
}
async handleAttachment(
attachment: Proto.IAttachmentPointer
): Promise<DownloadAttachmentType> {
): Promise<DownloadedAttachmentType> {
const cleaned = processAttachment(attachment);
return this.downloadAttachment(cleaned);
}
@ -2661,7 +2667,7 @@ export default class MessageReceiver {
downloadAttachment: (
attachment: ProcessedAttachment
) => Promise<DownloadAttachmentType>;
) => Promise<DownloadedAttachmentType>;
getStatus: () => SocketStatus;

View file

@ -3,15 +3,27 @@
import is from '@sindresorhus/is';
import moment from 'moment';
import { isNumber, padStart } from 'lodash';
import {
isNumber,
padStart,
isArrayBuffer,
isFunction,
isUndefined,
omit,
} from 'lodash';
import { arrayBufferToBlob, blobToArrayBuffer } from 'blob-util';
import { LoggerType } from './Logging';
import * as MIME from './MIME';
import { toLogFormat } from './errors';
import { SignalService } from '../protobuf';
import {
isImageTypeSupported,
isVideoTypeSupported,
} from '../util/GoogleChrome';
import { LocalizerType, ThemeType } from './Util';
import * as GoogleChrome from '../util/GoogleChrome';
import { scaleImageToLevel } from '../util/scaleImageToLevel';
const MAX_WIDTH = 300;
const MAX_HEIGHT = MAX_WIDTH * 1.5;
@ -39,7 +51,7 @@ export type AttachmentType = {
screenshot?: {
height: number;
width: number;
url: string;
url?: string;
contentType: MIME.MIMEType;
path: string;
};
@ -51,11 +63,16 @@ export type AttachmentType = {
cdnNumber?: number;
cdnId?: string;
cdnKey?: string;
data?: ArrayBuffer;
/** Legacy field. Used only for downloading old attachments */
id?: number;
};
export type DownloadedAttachmentType = AttachmentType & {
data: ArrayBuffer;
};
type BaseAttachmentDraftType = {
blurHash?: string;
contentType: MIME.MIMEType;
@ -82,13 +99,411 @@ export type AttachmentDraftType = {
export type ThumbnailType = {
height: number;
width: number;
url: string;
url?: string;
contentType: MIME.MIMEType;
path: string;
// Only used when quote needed to make an in-memory thumbnail
objectUrl?: string;
};
export async function migrateDataToFileSystem(
attachment: AttachmentType,
{
writeNewAttachmentData,
}: {
writeNewAttachmentData: (data: ArrayBuffer) => Promise<string>;
}
): Promise<AttachmentType> {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError("'writeNewAttachmentData' must be a function");
}
const { data } = attachment;
const attachmentHasData = !isUndefined(data);
const shouldSkipSchemaUpgrade = !attachmentHasData;
if (shouldSkipSchemaUpgrade) {
return attachment;
}
if (!isArrayBuffer(data)) {
throw new TypeError(
'Expected `attachment.data` to be an array buffer;' +
` got: ${typeof attachment.data}`
);
}
const path = await writeNewAttachmentData(data);
const attachmentWithoutData = omit({ ...attachment, path }, ['data']);
return attachmentWithoutData;
}
// // 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.
export function isValid(
rawAttachment?: AttachmentType
): rawAttachment is AttachmentType {
// 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:
export async function autoOrientJPEG(
attachment: AttachmentType,
_: unknown,
message?: { sendHQImages?: boolean }
): Promise<AttachmentType> {
if (!canBeTranscoded(attachment)) {
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 ? message.sendHQImages : false
);
const xcodedDataArrayBuffer = await blobToArrayBuffer(xcodedDataBlob);
// IMPORTANT: We overwrite the existing `data` `ArrayBuffer` losing the original
// image data. Ideally, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont want to overburden IndexedDB
// by potentially doubling stored image data.
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
const xcodedAttachment = {
// `digest` is no longer valid for auto-oriented image data, so we discard it:
...omit(attachment, 'digest'),
data: xcodedDataArrayBuffer,
size: xcodedDataArrayBuffer.byteLength,
};
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 doesnt support async testing:
// https://github.com/leebyron/testcheck-js/issues/45
export function _replaceUnicodeOrderOverridesSync(
attachment: AttachmentType
): AttachmentType {
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;
}
export const replaceUnicodeOrderOverrides = async (
attachment: AttachmentType
): Promise<AttachmentType> => {
return _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;
export async function replaceUnicodeV2(
attachment: AttachmentType
): Promise<AttachmentType> {
if (!is.string(attachment.fileName)) {
return attachment;
}
const fileName = attachment.fileName.replace(
V2_UNWANTED_UNICODE,
UNICODE_REPLACEMENT_CHARACTER
);
return {
...attachment,
fileName,
};
}
export function removeSchemaVersion({
attachment,
logger,
}: {
attachment: AttachmentType;
logger: LoggerType;
}): AttachmentType {
if (!exports.isValid(attachment)) {
logger.error(
'Attachment.removeSchemaVersion: Invalid input attachment:',
attachment
);
return attachment;
}
return omit(attachment, 'schemaVersion');
}
export function hasData(attachment: AttachmentType): boolean {
return (
attachment.data instanceof ArrayBuffer ||
ArrayBuffer.isView(attachment.data)
);
}
export function loadData(
readAttachmentData: (path: string) => Promise<ArrayBuffer>
): (attachment?: AttachmentType) => Promise<AttachmentType> {
if (!is.function_(readAttachmentData)) {
throw new TypeError("'readAttachmentData' must be a function");
}
return async (attachment?: AttachmentType): Promise<AttachmentType> => {
if (!isValid(attachment)) {
throw new TypeError("'attachment' is not valid");
}
const isAlreadyLoaded = Boolean(attachment.data);
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 };
};
}
export function deleteData(
deleteOnDisk: (path: string) => Promise<void>
): (attachment?: AttachmentType) => Promise<void> {
if (!is.function_(deleteOnDisk)) {
throw new TypeError('deleteData: deleteOnDisk must be a function');
}
return async (attachment?: AttachmentType): Promise<void> => {
if (!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);
}
};
}
const THUMBNAIL_SIZE = 150;
const THUMBNAIL_CONTENT_TYPE = MIME.IMAGE_PNG;
export async function captureDimensionsAndScreenshot(
attachment: AttachmentType,
params: {
writeNewAttachmentData: (data: ArrayBuffer) => Promise<string>;
getAbsoluteAttachmentPath: (path: string) => Promise<string>;
makeObjectUrl: (data: ArrayBuffer, contentType: MIME.MIMEType) => string;
revokeObjectUrl: (path: string) => void;
getImageDimensions: (params: {
objectUrl: string;
logger: LoggerType;
}) => { width: number; height: number };
makeImageThumbnail: (params: {
size: number;
objectUrl: string;
contentType: MIME.MIMEType;
logger: LoggerType;
}) => Promise<Blob>;
makeVideoScreenshot: (params: {
objectUrl: string;
contentType: MIME.MIMEType;
logger: LoggerType;
}) => Promise<Blob>;
logger: LoggerType;
}
): Promise<AttachmentType> {
const { contentType } = attachment;
const {
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions: getImageDimensionsFromURL,
makeImageThumbnail,
makeVideoScreenshot,
logger,
} = params;
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 getImageDimensionsFromURL({
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: string | undefined;
try {
const screenshotBuffer = await blobToArrayBuffer(
await makeVideoScreenshot({
objectUrl: absolutePath,
contentType: THUMBNAIL_CONTENT_TYPE,
logger,
})
);
screenshotObjectUrl = makeObjectUrl(
screenshotBuffer,
THUMBNAIL_CONTENT_TYPE
);
const { width, height } = await getImageDimensionsFromURL({
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 {
if (screenshotObjectUrl !== undefined) {
revokeObjectUrl(screenshotObjectUrl);
}
}
}
// UI-focused functions
export function getExtensionForDisplay({
@ -252,7 +667,7 @@ type DimensionsType = {
};
export function getImageDimensions(
attachment: AttachmentType,
attachment: Pick<AttachmentType, 'width' | 'height'>,
forcedWidth?: number
): DimensionsType {
const { height, width } = attachment;
@ -356,28 +771,7 @@ export function getAlt(
// Migration-related attachment stuff
export type Attachment = {
fileName?: string;
flags?: SignalService.AttachmentPointer.Flags;
contentType?: MIME.MIMEType;
size?: number;
data: ArrayBuffer;
// // Omit unused / deprecated keys:
// schemaVersion?: number;
// id?: string;
// width?: number;
// height?: number;
// thumbnail?: ArrayBuffer;
// key?: ArrayBuffer;
// digest?: ArrayBuffer;
} & Partial<AttachmentSchemaVersion3>;
type AttachmentSchemaVersion3 = {
path: string;
};
export const isVisualMedia = (attachment: Attachment): boolean => {
export const isVisualMedia = (attachment: AttachmentType): boolean => {
const { contentType } = attachment;
if (is.undefined(contentType)) {
@ -391,7 +785,7 @@ export const isVisualMedia = (attachment: Attachment): boolean => {
return MIME.isImage(contentType) || MIME.isVideo(contentType);
};
export const isFile = (attachment: Attachment): boolean => {
export const isFile = (attachment: AttachmentType): boolean => {
const { contentType } = attachment;
if (is.undefined(contentType)) {
@ -409,9 +803,7 @@ export const isFile = (attachment: Attachment): boolean => {
return true;
};
export const isVoiceMessage = (
attachment: Attachment | AttachmentType
): boolean => {
export const isVoiceMessage = (attachment: AttachmentType): boolean => {
const flag = SignalService.AttachmentPointer.Flags.VOICE_MESSAGE;
const hasFlag =
// eslint-disable-next-line no-bitwise
@ -438,8 +830,8 @@ export const save = async ({
saveAttachmentToDisk,
timestamp,
}: {
attachment: Attachment;
index: number;
attachment: AttachmentType;
index?: number;
readAttachmentData: (relativePath: string) => Promise<ArrayBuffer>;
saveAttachmentToDisk: (options: {
data: ArrayBuffer;
@ -447,13 +839,15 @@ export const save = async ({
}) => Promise<{ name: string; fullPath: string }>;
timestamp?: number;
}): Promise<string | null> => {
if (!attachment.path && !attachment.data) {
let data: ArrayBuffer;
if (attachment.path) {
data = await readAttachmentData(attachment.path);
} else if (attachment.data) {
data = attachment.data;
} else {
throw new Error('Attachment had neither path nor data');
}
const data = attachment.path
? await readAttachmentData(attachment.path)
: attachment.data;
const name = getSuggestedFilename({ attachment, timestamp, index });
const result = await saveAttachmentToDisk({
@ -473,7 +867,7 @@ export const getSuggestedFilename = ({
timestamp,
index,
}: {
attachment: Attachment;
attachment: AttachmentType;
timestamp?: number | Date;
index?: number;
}): string => {
@ -493,7 +887,7 @@ export const getSuggestedFilename = ({
};
export const getFileExtension = (
attachment: Attachment
attachment: AttachmentType
): string | undefined => {
if (!attachment.contentType) {
return undefined;

View file

@ -31,3 +31,7 @@ export const isAudio = (value: string): value is MIMEType =>
Boolean(value) && value.startsWith('audio/') && !value.endsWith('aiff');
export const isLongMessage = (value: unknown): value is MIMEType =>
value === LONG_MESSAGE;
export const fromString = (value: string): MIMEType => {
return value as MIMEType;
};

View file

@ -3,7 +3,7 @@
/* eslint-disable camelcase */
import { Attachment } from './Attachment';
import { AttachmentType } from './Attachment';
import { ContactType } from './Contact';
import { IndexableBoolean, IndexablePresence } from './IndexedDB';
@ -19,7 +19,7 @@ export type IncomingMessage = Readonly<
{
type: 'incoming';
// Required
attachments: Array<Attachment>;
attachments: Array<AttachmentType>;
id: string;
received_at: number;
@ -44,7 +44,7 @@ export type OutgoingMessage = Readonly<
type: 'outgoing';
// Required
attachments: Array<Attachment>;
attachments: Array<AttachmentType>;
delivered: number;
delivered_to: Array<string>;
destination: string; // PhoneNumber

10
ts/types/errors.ts Normal file
View file

@ -0,0 +1,10 @@
// Copyright 2018-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function toLogFormat(error: unknown): string {
if (error instanceof Error && error.stack) {
return error.stack;
}
return String(error);
}

View file

@ -6,7 +6,7 @@ import * as IndexedDB from '../IndexedDB';
import { Message, UserMessage } from '../Message';
const hasAttachment = (
predicate: (value: Attachment.Attachment) => boolean
predicate: (value: Attachment.AttachmentType) => boolean
) => (message: UserMessage): IndexedDB.IndexablePresence =>
IndexedDB.toIndexablePresence(message.attachments.some(predicate));
@ -30,7 +30,7 @@ export const initializeAttachmentMetadata = async (
}
const attachments = message.attachments.filter(
(attachment: Attachment.Attachment) =>
(attachment: Attachment.AttachmentType) =>
attachment.contentType !== 'text/x-signal-plain'
);
const hasAttachments = IndexedDB.toIndexableBoolean(attachments.length > 0);

View file

@ -1,13 +1,11 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DownloadAttachmentType } from '../textsecure.d';
import { AttachmentType } from '../types/Attachment';
import { AttachmentType, DownloadedAttachmentType } from '../types/Attachment';
export async function downloadAttachment(
attachmentData: AttachmentType
): Promise<DownloadAttachmentType | null> {
): Promise<DownloadedAttachmentType | null> {
let migratedAttachment: AttachmentType;
const { id: legacyId } = attachmentData;

View file

@ -1896,10 +1896,24 @@
},
{
"rule": "jQuery-append(",
"path": "node_modules/blob-util/dist/blob-util.js",
"line": " bb.append(ary[i]);",
"path": "node_modules/blob-util/dist/blob-util.cjs.js",
"line": " builder.append(parts[i]);",
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
"updated": "2021-07-14T23:12:47.258Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/blob-util/dist/blob-util.es.js",
"line": " builder.append(parts[i]);",
"reasonCategory": "falseMatch",
"updated": "2021-07-14T23:12:47.258Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/blob-util/dist/blob-util.js",
"line": " builder.append(parts[i]);",
"reasonCategory": "falseMatch",
"updated": "2021-07-14T23:12:47.258Z"
},
{
"rule": "jQuery-append(",
@ -1907,13 +1921,6 @@
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-append(",
"path": "node_modules/blob/index.js",
"line": " bb.append(ary[i]);",
"reasonCategory": "falseMatch",
"updated": "2018-09-19T18:13:29.628Z"
},
{
"rule": "jQuery-wrap(",
"path": "node_modules/boom/lib/index.js",

View file

@ -8,6 +8,7 @@ import {
AttachmentType,
InMemoryAttachmentDraftType,
OnDiskAttachmentDraftType,
isGIF,
} from '../types/Attachment';
import type { StickerPackType as StickerPackDBType } from '../sql/Interface';
import * as Stickers from '../types/Stickers';
@ -63,7 +64,7 @@ const LINK_PREVIEW_TIMEOUT = 60 * 1000;
window.Whisper = window.Whisper || {};
const { Whisper } = window;
const { Message, MIME, VisualAttachment, Attachment } = window.Signal.Types;
const { Message, MIME, VisualAttachment } = window.Signal.Types;
const {
copyIntoTempDirectory,
@ -783,7 +784,7 @@ Whisper.ConversationView = Whisper.View.extend({
message.markAttachmentAsCorrupted(options.attachment);
};
const showVisualAttachment = (options: {
attachment: typeof Attachment;
attachment: AttachmentType;
messageId: string;
showSingle?: boolean;
}) => {
@ -2803,8 +2804,8 @@ Whisper.ConversationView = Whisper.View.extend({
timestamp,
isDangerous,
}: {
attachment: typeof Attachment;
timestamp: string;
attachment: AttachmentType;
timestamp: number;
isDangerous: boolean;
}) {
if (isDangerous) {
@ -3012,7 +3013,7 @@ Whisper.ConversationView = Whisper.View.extend({
attachment,
messageId,
}: {
attachment: typeof Attachment;
attachment: AttachmentType;
messageId: string;
showSingle?: boolean;
}) {
@ -3037,14 +3038,17 @@ Whisper.ConversationView = Whisper.View.extend({
return;
}
const attachments = message.get('attachments') || [];
const attachments: Array<AttachmentType> = message.get('attachments') || [];
const loop = isGIF(attachments);
const media = attachments
.filter((item: any) => item.thumbnail && !item.pending && !item.error)
.map((item: any, index: number) => ({
objectURL: getAbsoluteAttachmentPath(item.path),
.filter(item => item.thumbnail && !item.pending && !item.error)
.map((item, index) => ({
objectURL: getAbsoluteAttachmentPath(item.path ?? ''),
path: item.path,
contentType: item.contentType,
loop,
index,
message,
attachment: item,
@ -3052,9 +3056,10 @@ Whisper.ConversationView = Whisper.View.extend({
if (media.length === 1) {
const props = {
objectURL: getAbsoluteAttachmentPath(path),
objectURL: getAbsoluteAttachmentPath(path ?? ''),
contentType,
caption: attachment.caption,
loop,
onSave: () => {
const timestamp = message.get('sent_at');
this.downloadAttachment({ attachment, timestamp, message });
@ -3095,6 +3100,7 @@ Whisper.ConversationView = Whisper.View.extend({
const props = {
media,
loop,
selectedIndex: selectedIndex >= 0 ? selectedIndex : 0,
onSave,
};

41
ts/window.d.ts vendored
View file

@ -18,7 +18,7 @@ import {
ReactionAttributesType,
ReactionModelType,
} from './model-types.d';
import { TextSecureType, DownloadAttachmentType } from './textsecure.d';
import { TextSecureType } from './textsecure.d';
import { Storage } from './textsecure/Storage';
import {
ChallengeHandler,
@ -38,7 +38,7 @@ import { LocalizerType, BodyRangesType, BodyRangeType } from './types/Util';
import * as Attachment from './types/Attachment';
import * as MIME from './types/MIME';
import * as Contact from './types/Contact';
import * as Errors from '../js/modules/types/errors';
import * as Errors from './types/errors';
import { ConversationController } from './ConversationController';
import { ReduxActions } from './state/types';
import { createStore } from './state/createStore';
@ -104,7 +104,7 @@ import { Quote } from './components/conversation/Quote';
import { StagedLinkPreview } from './components/conversation/StagedLinkPreview';
import { DisappearingTimeDialog } from './components/DisappearingTimeDialog';
import { MIMEType } from './types/MIME';
import { AttachmentType } from './types/Attachment';
import { DownloadedAttachmentType } from './types/Attachment';
import { ElectronLocaleType } from './util/mapToSupportLocale';
import { SignalProtocolStore } from './SignalProtocolStore';
import { Context as SignalContext } from './context';
@ -324,8 +324,8 @@ declare global {
copyIntoAttachmentsDirectory: (path: string) => Promise<string>;
upgradeMessageSchema: (attributes: unknown) => WhatIsThis;
processNewAttachment: (
attachment: DownloadAttachmentType
) => Promise<AttachmentType>;
attachment: DownloadedAttachmentType
) => Promise<DownloadedAttachmentType>;
copyIntoTempDirectory: any;
deleteDraftFile: (path: string) => Promise<void>;
@ -339,36 +339,7 @@ declare global {
writeNewDraftData: any;
};
Types: {
Attachment: {
save: any;
path: string;
pending: boolean;
flags: number;
size: number;
screenshot: {
path: string;
};
thumbnail: {
path: string;
objectUrl: string;
};
contentType: MIMEType;
error: unknown;
caption: string;
migrateDataToFileSystem: (
attachment: WhatIsThis,
options: unknown
) => WhatIsThis;
isVoiceMessage: (attachments: unknown) => boolean;
isImage: typeof Attachment.isImage;
isGIF: typeof Attachment.isGIF;
isVideo: typeof Attachment.isVideo;
isAudio: typeof Attachment.isAudio;
getUploadSizeLimitKb: typeof Attachment.getUploadSizeLimitKb;
};
Attachment: typeof Attachment;
MIME: typeof MIME;
Contact: typeof Contact;
Conversation: {

View file

@ -4721,17 +4721,10 @@ bl@^4.0.1, bl@^4.0.3:
inherits "^2.0.4"
readable-stream "^3.4.0"
blob-util@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-1.3.0.tgz#dbb4e8caffd50b5720d347e1169b6369ba34fe95"
integrity sha512-cjmYgWj8BQwoX+95rKkWvITL6PiEhSr19sX8qLRu+O6J2qmWmgUvxqhqJn425RFAwLovdDNnsCQ64RRHXjsXSg==
dependencies:
blob "0.0.4"
native-or-lie "1.0.2"
blob@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/blob/-/blob-0.0.4.tgz#bcf13052ca54463f30f9fc7e95b9a47630a94921"
blob-util@2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb"
integrity sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==
block-stream@*:
version "0.0.9"
@ -11357,12 +11350,6 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"
lie@*:
version "3.2.0"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.2.0.tgz#4f13f2f8bbb027d383db338c43043545791aa8dc"
dependencies:
immediate "~3.0.5"
lie@~3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e"
@ -12431,12 +12418,6 @@ napi-build-utils@^1.0.1:
resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.1.tgz#1381a0f92c39d66bf19852e7873432fc2123e508"
integrity sha512-boQj1WFgQH3v4clhu3mTNfP+vOBxorDlE8EKiMjUlLG3C4qAESnn9AxIOkFgTR2c9LtzNjPrjS60cT27ZKBhaA==
native-or-lie@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/native-or-lie/-/native-or-lie-1.0.2.tgz#c870ee0ba0bf0ff11350595d216cfea68a6d8086"
dependencies:
lie "*"
natural-compare@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"