Move scale/resize and attachment logic to typescript
This commit is contained in:
parent
6992305e27
commit
c7565fe7b3
|
@ -1,7 +1,5 @@
|
|||
const electron = require('electron');
|
||||
|
||||
const Errors = require('../js/modules/types/errors');
|
||||
|
||||
const { app, dialog, clipboard } = electron;
|
||||
const { redactAll } = require('../js/modules/privacy');
|
||||
|
||||
|
@ -11,9 +9,9 @@ let copyErrorAndQuitText = 'Copy error and quit';
|
|||
|
||||
function handleError(prefix, error) {
|
||||
if (console._error) {
|
||||
console._error(`${prefix}:`, Errors.toLogFormat(error));
|
||||
console._error(`${prefix}:`, error);
|
||||
}
|
||||
console.error(`${prefix}:`, Errors.toLogFormat(error));
|
||||
console.error(`${prefix}:`, error);
|
||||
|
||||
if (app.isReady()) {
|
||||
// title field is not shown on macOS, so we don't use it
|
||||
|
|
|
@ -63,7 +63,6 @@
|
|||
window.setImmediate = window.nodeSetImmediate;
|
||||
window.globalOnlineStatus = true; // default to true as we don't get an event on app start
|
||||
window.getGlobalOnlineStatus = () => window.globalOnlineStatus;
|
||||
const { Views } = window.Signal;
|
||||
|
||||
window.log.info('background page reloaded');
|
||||
window.log.info('environment:', window.getEnvironment());
|
||||
|
@ -85,7 +84,6 @@
|
|||
Whisper.events = _.clone(Backbone.Events);
|
||||
Whisper.events.isListenedTo = eventName =>
|
||||
Whisper.events._events ? !!Whisper.events._events[eventName] : false;
|
||||
const cancelInitializationMessage = Views.Initialization.setMessage();
|
||||
|
||||
window.log.info('Storage fetch');
|
||||
storage.fetch();
|
||||
|
@ -168,10 +166,6 @@
|
|||
await window.Signal.Logs.deleteAll();
|
||||
}
|
||||
|
||||
Views.Initialization.setMessage(window.i18n('optimizingApplication'));
|
||||
|
||||
Views.Initialization.setMessage(window.i18n('loading'));
|
||||
|
||||
const themeSetting = window.Events.getThemeSetting();
|
||||
const newThemeSetting = mapOldThemeToNew(themeSetting);
|
||||
window.Events.setThemeSetting(newThemeSetting);
|
||||
|
@ -238,7 +232,6 @@
|
|||
connect(true);
|
||||
});
|
||||
|
||||
cancelInitializationMessage();
|
||||
const appView = new Whisper.AppView({
|
||||
el: $('body'),
|
||||
});
|
||||
|
|
|
@ -1,43 +0,0 @@
|
|||
const loadImage = require('blueimp-load-image');
|
||||
|
||||
const DEFAULT_JPEG_QUALITY = 0.85;
|
||||
|
||||
// File | Blob | URLString -> LoadImageOptions -> Promise<DataURLString>
|
||||
//
|
||||
// Documentation for `options` (`LoadImageOptions`):
|
||||
// https://github.com/blueimp/JavaScript-Load-Image/tree/v2.18.0#options
|
||||
exports.autoOrientImage = (fileOrBlobOrURL, options = {}) => {
|
||||
const optionsWithDefaults = Object.assign(
|
||||
{
|
||||
type: 'image/jpeg',
|
||||
quality: DEFAULT_JPEG_QUALITY,
|
||||
},
|
||||
options,
|
||||
{
|
||||
canvas: true,
|
||||
orientation: true,
|
||||
maxHeight: 4096, // ATTACHMENT_DEFAULT_MAX_SIDE
|
||||
maxWidth: 4096, // ATTACHMENT_DEFAULT_MAX_SIDE
|
||||
}
|
||||
);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
loadImage(
|
||||
fileOrBlobOrURL,
|
||||
canvasOrError => {
|
||||
if (canvasOrError.type === 'error') {
|
||||
const error = new Error('autoOrientImage: Failed to process image');
|
||||
error.cause = canvasOrError;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasOrError;
|
||||
const dataURL = canvas.toDataURL(optionsWithDefaults.type, optionsWithDefaults.quality);
|
||||
|
||||
resolve(dataURL);
|
||||
},
|
||||
optionsWithDefaults
|
||||
);
|
||||
});
|
||||
};
|
|
@ -4,180 +4,18 @@
|
|||
/* eslint-disable camelcase, no-bitwise */
|
||||
|
||||
module.exports = {
|
||||
arrayBufferToBase64,
|
||||
bytesFromString,
|
||||
concatenateBytes,
|
||||
constantTimeEqual,
|
||||
decryptSymmetric,
|
||||
encryptAesCtr,
|
||||
encryptSymmetric,
|
||||
getRandomBytes,
|
||||
getZeroes,
|
||||
hmacSha256,
|
||||
};
|
||||
|
||||
function arrayBufferToBase64(arrayBuffer) {
|
||||
return dcodeIO.ByteBuffer.wrap(arrayBuffer).toString('base64');
|
||||
}
|
||||
// Utility
|
||||
|
||||
function bytesFromString(string) {
|
||||
return dcodeIO.ByteBuffer.wrap(string, 'utf8').toArrayBuffer();
|
||||
}
|
||||
|
||||
// High-level Operations
|
||||
|
||||
const IV_LENGTH = 16;
|
||||
const MAC_LENGTH = 16;
|
||||
const NONCE_LENGTH = 16;
|
||||
|
||||
async function encryptSymmetric(key, plaintext) {
|
||||
const iv = getZeroes(IV_LENGTH);
|
||||
const nonce = getRandomBytes(NONCE_LENGTH);
|
||||
|
||||
const cipherKey = await hmacSha256(key, nonce);
|
||||
const macKey = await hmacSha256(key, cipherKey);
|
||||
|
||||
const cipherText = await _encrypt_aes256_CBC_PKCSPadding(cipherKey, iv, plaintext);
|
||||
const mac = _getFirstBytes(await hmacSha256(macKey, cipherText), MAC_LENGTH);
|
||||
|
||||
return concatenateBytes(nonce, cipherText, mac);
|
||||
}
|
||||
|
||||
async function decryptSymmetric(key, data) {
|
||||
const iv = getZeroes(IV_LENGTH);
|
||||
|
||||
const nonce = _getFirstBytes(data, NONCE_LENGTH);
|
||||
const cipherText = _getBytes(data, NONCE_LENGTH, data.byteLength - NONCE_LENGTH - MAC_LENGTH);
|
||||
const theirMac = _getBytes(data, data.byteLength - MAC_LENGTH, MAC_LENGTH);
|
||||
|
||||
const cipherKey = await hmacSha256(key, nonce);
|
||||
const macKey = await hmacSha256(key, cipherKey);
|
||||
|
||||
const ourMac = _getFirstBytes(await hmacSha256(macKey, cipherText), MAC_LENGTH);
|
||||
if (!constantTimeEqual(theirMac, ourMac)) {
|
||||
throw new Error('decryptSymmetric: Failed to decrypt; MAC verification failed');
|
||||
}
|
||||
|
||||
return _decrypt_aes256_CBC_PKCSPadding(cipherKey, iv, cipherText);
|
||||
}
|
||||
|
||||
function constantTimeEqual(left, right) {
|
||||
if (left.byteLength !== right.byteLength) {
|
||||
return false;
|
||||
}
|
||||
let result = 0;
|
||||
const ta1 = new Uint8Array(left);
|
||||
const ta2 = new Uint8Array(right);
|
||||
for (let i = 0, max = left.byteLength; i < max; i += 1) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
result |= ta1[i] ^ ta2[i];
|
||||
}
|
||||
return result === 0;
|
||||
}
|
||||
|
||||
// Encryption
|
||||
|
||||
async function hmacSha256(key, plaintext) {
|
||||
const algorithm = {
|
||||
name: 'HMAC',
|
||||
hash: 'SHA-256',
|
||||
};
|
||||
const extractable = false;
|
||||
|
||||
const cryptoKey = await window.crypto.subtle.importKey('raw', key, algorithm, extractable, [
|
||||
'sign',
|
||||
]);
|
||||
|
||||
return window.crypto.subtle.sign(algorithm, cryptoKey, plaintext);
|
||||
}
|
||||
|
||||
async function _encrypt_aes256_CBC_PKCSPadding(key, iv, plaintext) {
|
||||
const algorithm = {
|
||||
name: 'AES-CBC',
|
||||
iv,
|
||||
};
|
||||
const extractable = false;
|
||||
|
||||
const cryptoKey = await window.crypto.subtle.importKey('raw', key, algorithm, extractable, [
|
||||
'encrypt',
|
||||
]);
|
||||
|
||||
return window.crypto.subtle.encrypt(algorithm, cryptoKey, plaintext);
|
||||
}
|
||||
|
||||
async function _decrypt_aes256_CBC_PKCSPadding(key, iv, plaintext) {
|
||||
const algorithm = {
|
||||
name: 'AES-CBC',
|
||||
iv,
|
||||
};
|
||||
const extractable = false;
|
||||
|
||||
const cryptoKey = await window.crypto.subtle.importKey('raw', key, algorithm, extractable, [
|
||||
'decrypt',
|
||||
]);
|
||||
return window.crypto.subtle.decrypt(algorithm, cryptoKey, plaintext);
|
||||
}
|
||||
|
||||
async function encryptAesCtr(key, plaintext, counter) {
|
||||
const extractable = false;
|
||||
const algorithm = {
|
||||
name: 'AES-CTR',
|
||||
counter: new Uint8Array(counter),
|
||||
length: 128,
|
||||
};
|
||||
|
||||
const cryptoKey = await crypto.subtle.importKey('raw', key, algorithm, extractable, ['encrypt']);
|
||||
|
||||
const ciphertext = await crypto.subtle.encrypt(algorithm, cryptoKey, plaintext);
|
||||
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
// Utility
|
||||
|
||||
function getRandomBytes(n) {
|
||||
const bytes = new Uint8Array(n);
|
||||
window.crypto.getRandomValues(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getZeroes(n) {
|
||||
const result = new Uint8Array(n);
|
||||
|
||||
const value = 0;
|
||||
const startIndex = 0;
|
||||
const endExclusive = n;
|
||||
result.fill(value, startIndex, endExclusive);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function concatenateBytes(...elements) {
|
||||
const length = elements.reduce((total, element) => total + element.byteLength, 0);
|
||||
|
||||
const result = new Uint8Array(length);
|
||||
let position = 0;
|
||||
|
||||
for (let i = 0, max = elements.length; i < max; i += 1) {
|
||||
const element = new Uint8Array(elements[i]);
|
||||
result.set(element, position);
|
||||
position += element.byteLength;
|
||||
}
|
||||
if (position !== result.length) {
|
||||
throw new Error('problem concatenating!');
|
||||
}
|
||||
|
||||
return result.buffer;
|
||||
}
|
||||
|
||||
// Internal-only
|
||||
|
||||
function _getFirstBytes(data, n) {
|
||||
const source = new Uint8Array(data);
|
||||
return source.subarray(0, n);
|
||||
}
|
||||
|
||||
function _getBytes(data, start, n) {
|
||||
const source = new Uint8Array(data);
|
||||
return source.subarray(start, start + n);
|
||||
}
|
||||
|
|
|
@ -18,107 +18,14 @@ const {
|
|||
const { SessionInboxView } = require('../../ts/components/SessionInboxView');
|
||||
|
||||
// Types
|
||||
const AttachmentType = require('./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 MessageType = require('./types/message');
|
||||
const MIME = require('../../ts/types/MIME');
|
||||
const SettingsType = require('../../ts/types/Settings');
|
||||
|
||||
// Views
|
||||
const Initialization = require('./views/initialization');
|
||||
|
||||
function initializeMigrations({ userDataPath, Attachments, Type, VisualType, logger }) {
|
||||
if (!Attachments) {
|
||||
return null;
|
||||
}
|
||||
const {
|
||||
getPath,
|
||||
createReader,
|
||||
createAbsolutePathGetter,
|
||||
createWriterForNew,
|
||||
createWriterForExisting,
|
||||
} = Attachments;
|
||||
const {
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeVideoScreenshot,
|
||||
} = VisualType;
|
||||
|
||||
const attachmentsPath = getPath(userDataPath);
|
||||
const readAttachmentData = createReader(attachmentsPath);
|
||||
const loadAttachmentData = Type.loadData(readAttachmentData);
|
||||
const loadPreviewData = MessageType.loadPreviewData(loadAttachmentData);
|
||||
const loadQuoteData = MessageType.loadQuoteData(loadAttachmentData);
|
||||
const getAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
||||
const deleteOnDisk = Attachments.createDeleter(attachmentsPath);
|
||||
const writeNewAttachmentData = createWriterForNew(attachmentsPath);
|
||||
|
||||
return {
|
||||
attachmentsPath,
|
||||
deleteAttachmentData: deleteOnDisk,
|
||||
deleteExternalMessageFiles: MessageType.deleteAllExternalFiles({
|
||||
deleteAttachmentData: Type.deleteData(deleteOnDisk),
|
||||
deleteOnDisk,
|
||||
}),
|
||||
getAbsoluteAttachmentPath,
|
||||
loadAttachmentData,
|
||||
loadMessage: MessageType.createAttachmentLoader(loadAttachmentData),
|
||||
loadPreviewData,
|
||||
loadQuoteData,
|
||||
readAttachmentData,
|
||||
processNewAttachment: attachment =>
|
||||
MessageType.processNewAttachment(attachment, {
|
||||
writeNewAttachmentData,
|
||||
getAbsoluteAttachmentPath,
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeVideoScreenshot,
|
||||
logger,
|
||||
}),
|
||||
upgradeMessageSchema: (message, options = {}) => {
|
||||
const { maxVersion } = options;
|
||||
|
||||
return MessageType.upgradeSchema(message, {
|
||||
writeNewAttachmentData,
|
||||
getAbsoluteAttachmentPath,
|
||||
makeObjectUrl,
|
||||
revokeObjectUrl,
|
||||
getImageDimensions,
|
||||
makeImageThumbnail,
|
||||
makeVideoScreenshot,
|
||||
logger,
|
||||
maxVersion,
|
||||
});
|
||||
},
|
||||
writeMessageAttachments: MessageType.createAttachmentDataWriter({
|
||||
writeExistingAttachmentData: createWriterForExisting(attachmentsPath),
|
||||
logger,
|
||||
}),
|
||||
writeNewAttachmentData: createWriterForNew(attachmentsPath),
|
||||
writeAttachment: ({ data, path }) => createWriterForExisting(attachmentsPath)({ data, path }),
|
||||
};
|
||||
}
|
||||
|
||||
exports.setup = (options = {}) => {
|
||||
const { Attachments, userDataPath, logger } = options;
|
||||
|
||||
exports.setup = () => {
|
||||
Data.init();
|
||||
|
||||
const Migrations = initializeMigrations({
|
||||
userDataPath,
|
||||
Attachments,
|
||||
Type: AttachmentType,
|
||||
VisualType: VisualAttachment,
|
||||
logger,
|
||||
});
|
||||
|
||||
const Components = {
|
||||
SessionInboxView,
|
||||
SessionRegistrationView,
|
||||
|
@ -126,14 +33,7 @@ exports.setup = (options = {}) => {
|
|||
};
|
||||
|
||||
const Types = {
|
||||
Attachment: AttachmentType,
|
||||
Contact,
|
||||
Conversation,
|
||||
Errors,
|
||||
Message: MessageType,
|
||||
MIME,
|
||||
Settings: SettingsType,
|
||||
VisualAttachment,
|
||||
};
|
||||
|
||||
const Views = {
|
||||
|
@ -146,7 +46,6 @@ exports.setup = (options = {}) => {
|
|||
Data,
|
||||
Emoji,
|
||||
LinkPreviews,
|
||||
Migrations,
|
||||
Notifications,
|
||||
OS,
|
||||
Settings,
|
||||
|
|
|
@ -1,327 +0,0 @@
|
|||
const is = require('@sindresorhus/is');
|
||||
|
||||
const AttachmentTS = require('../../../ts/types/Attachment');
|
||||
const GoogleChrome = require('../../../ts/util/GoogleChrome');
|
||||
const MIME = require('../../../ts/types/MIME');
|
||||
const { toLogFormat } = require('./errors');
|
||||
const { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } = require('blob-util');
|
||||
const { autoOrientImage } = require('../auto_orient_image');
|
||||
const { migrateDataToFileSystem } = require('./attachment/migrate_data_to_file_system');
|
||||
|
||||
// // Incoming message attachment fields
|
||||
// {
|
||||
// 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;
|
||||
};
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
// 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 = Object.assign({}, 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;
|
||||
};
|
||||
// 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 = Object.assign({}, 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 = Object.assign({}, 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 Object.assign({}, attachment, { data });
|
||||
};
|
||||
};
|
||||
|
||||
// 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.isVoiceMessage = AttachmentTS.isVoiceMessage;
|
||||
exports.save = AttachmentTS.save;
|
||||
exports.getFileExtension = AttachmentTS.getFileExtension;
|
||||
exports.arrayBufferFromFile = AttachmentTS.arrayBufferFromFile;
|
||||
|
||||
const THUMBNAIL_SIZE = 200;
|
||||
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);
|
||||
}
|
||||
};
|
|
@ -1,33 +0,0 @@
|
|||
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(Object.assign({}, attachment, { path }), ['data']);
|
||||
return attachmentWithoutData;
|
||||
};
|
|
@ -1,83 +0,0 @@
|
|||
/* global crypto */
|
||||
|
||||
const { isFunction } = require('lodash');
|
||||
const { arrayBufferToBase64 } = require('../crypto');
|
||||
|
||||
async function computeHash(arraybuffer) {
|
||||
const hash = await crypto.subtle.digest({ name: 'SHA-512' }, arraybuffer);
|
||||
return arrayBufferToBase64(hash);
|
||||
}
|
||||
|
||||
function buildAvatarUpdater({ field }) {
|
||||
return async (conversation, data, options = {}) => {
|
||||
if (!conversation) {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
const avatar = conversation[field];
|
||||
const { writeNewAttachmentData, deleteAttachmentData } = options;
|
||||
if (!isFunction(writeNewAttachmentData)) {
|
||||
throw new Error('Conversation.buildAvatarUpdater: writeNewAttachmentData must be a function');
|
||||
}
|
||||
if (!isFunction(deleteAttachmentData)) {
|
||||
throw new Error('Conversation.buildAvatarUpdater: deleteAttachmentData must be a function');
|
||||
}
|
||||
|
||||
const newHash = await computeHash(data);
|
||||
|
||||
if (!avatar || !avatar.hash) {
|
||||
return {
|
||||
...conversation,
|
||||
[field]: {
|
||||
hash: newHash,
|
||||
path: await writeNewAttachmentData(data),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { hash, path } = avatar;
|
||||
|
||||
if (hash === newHash) {
|
||||
return conversation;
|
||||
}
|
||||
|
||||
await deleteAttachmentData(path);
|
||||
|
||||
return {
|
||||
...conversation,
|
||||
[field]: {
|
||||
hash: newHash,
|
||||
path: await writeNewAttachmentData(data),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const maybeUpdateAvatar = buildAvatarUpdater({ field: 'avatar' });
|
||||
|
||||
async function deleteExternalFiles(conversation, options = {}) {
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { deleteAttachmentData } = options;
|
||||
if (!isFunction(deleteAttachmentData)) {
|
||||
throw new Error('Conversation.buildAvatarUpdater: deleteAttachmentData must be a function');
|
||||
}
|
||||
|
||||
const { avatar, profileAvatar } = conversation;
|
||||
|
||||
if (avatar && avatar.path) {
|
||||
await deleteAttachmentData(avatar.path);
|
||||
}
|
||||
|
||||
if (profileAvatar && profileAvatar.path) {
|
||||
await deleteAttachmentData(profileAvatar.path);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deleteExternalFiles,
|
||||
maybeUpdateAvatar,
|
||||
arrayBufferToBase64,
|
||||
};
|
|
@ -1 +0,0 @@
|
|||
export function toLogFormat(error: any): string;
|
|
@ -1,650 +0,0 @@
|
|||
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 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 });
|
||||
};
|
||||
|
||||
// _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: message => message,
|
||||
});
|
||||
// IMPORTANT: We’ve 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 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 }),
|
||||
{
|
||||
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;
|
||||
};
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
const { isNumber } = require('lodash');
|
||||
|
||||
exports.isValid = value => isNumber(value) && value >= 0;
|
|
@ -1,118 +0,0 @@
|
|||
/* eslint-disable more/no-then */
|
||||
/* global document, URL, Blob */
|
||||
|
||||
const loadImage = require('blueimp-load-image');
|
||||
const { toLogFormat } = require('./errors');
|
||||
|
||||
const dataURLToBlobSync = require('blueimp-canvas-to-blob');
|
||||
|
||||
const { blobToArrayBuffer } = require('blob-util');
|
||||
const DecryptedAttachmentsManager = require('../../../ts/session/crypto/DecryptedAttachmentsManager');
|
||||
|
||||
exports.blobToArrayBuffer = blobToArrayBuffer;
|
||||
|
||||
exports.getImageDimensions = ({ objectUrl, logger }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = document.createElement('img');
|
||||
|
||||
image.addEventListener('load', () => {
|
||||
resolve({
|
||||
height: image.naturalHeight,
|
||||
width: image.naturalWidth,
|
||||
});
|
||||
});
|
||||
image.addEventListener('error', error => {
|
||||
logger.error('getImageDimensions error', toLogFormat(error));
|
||||
reject(error);
|
||||
});
|
||||
// TODO image/jpeg is hard coded, but it does not look to cause any issues
|
||||
DecryptedAttachmentsManager.getDecryptedMediaUrl(objectUrl, 'image/jpg').then(decryptedUrl => {
|
||||
image.src = decryptedUrl;
|
||||
});
|
||||
});
|
||||
|
||||
exports.makeImageThumbnail = ({ size, objectUrl, contentType = 'image/png', logger }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const image = document.createElement('img');
|
||||
|
||||
image.addEventListener('load', () => {
|
||||
// using components/blueimp-load-image
|
||||
|
||||
// first, make the correct size
|
||||
let canvas = loadImage.scale(image, {
|
||||
canvas: true,
|
||||
cover: true,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
});
|
||||
|
||||
// then crop
|
||||
canvas = loadImage.scale(canvas, {
|
||||
canvas: true,
|
||||
crop: true,
|
||||
maxWidth: size,
|
||||
maxHeight: size,
|
||||
minWidth: size,
|
||||
minHeight: size,
|
||||
});
|
||||
|
||||
const blob = dataURLToBlobSync(canvas.toDataURL(contentType));
|
||||
|
||||
resolve(blob);
|
||||
});
|
||||
|
||||
image.addEventListener('error', error => {
|
||||
logger.error('makeImageThumbnail error', toLogFormat(error));
|
||||
reject(error);
|
||||
});
|
||||
|
||||
DecryptedAttachmentsManager.getDecryptedMediaUrl(objectUrl, contentType).then(decryptedUrl => {
|
||||
image.src = decryptedUrl;
|
||||
});
|
||||
});
|
||||
|
||||
exports.makeVideoScreenshot = ({ objectUrl, contentType = 'image/png', logger }) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
|
||||
function capture() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const image = dataURLToBlobSync(canvas.toDataURL(contentType));
|
||||
|
||||
video.removeEventListener('canplay', capture);
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
resolve(image);
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', capture);
|
||||
video.addEventListener('error', error => {
|
||||
logger.error('makeVideoScreenshot error', toLogFormat(error));
|
||||
reject(error);
|
||||
});
|
||||
|
||||
DecryptedAttachmentsManager.getDecryptedMediaUrl(objectUrl, contentType).then(decryptedUrl => {
|
||||
video.src = decryptedUrl;
|
||||
video.muted = true;
|
||||
// for some reason, this is to be started, otherwise the generated thumbnail will be empty
|
||||
video.play();
|
||||
});
|
||||
});
|
||||
|
||||
exports.makeObjectUrl = (data, contentType) => {
|
||||
const blob = new Blob([data], {
|
||||
type: contentType,
|
||||
});
|
||||
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
exports.revokeObjectUrl = objectUrl => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
11
package.json
11
package.json
|
@ -56,8 +56,8 @@
|
|||
"auto-bind": "^4.0.0",
|
||||
"backbone": "1.3.3",
|
||||
"better-sqlite3": "https://github.com/signalapp/better-sqlite3#ad0db5dd09c0ea4007b1c46bd4f7273827803347",
|
||||
"blob-util": "1.3.0",
|
||||
"blueimp-canvas-to-blob": "3.14.0",
|
||||
"blob-util": "2.0.2",
|
||||
"blueimp-canvas-to-blob": "^3.29.0",
|
||||
"blueimp-load-image": "5.14.0",
|
||||
"buffer-crc32": "0.2.13",
|
||||
"bunyan": "1.8.12",
|
||||
|
@ -80,6 +80,7 @@
|
|||
"fs-extra": "9.0.0",
|
||||
"glob": "7.1.2",
|
||||
"he": "1.2.0",
|
||||
"image-type": "^4.1.0",
|
||||
"ip2country": "1.0.1",
|
||||
"jquery": "3.3.1",
|
||||
"jsbn": "1.1.0",
|
||||
|
@ -268,7 +269,11 @@
|
|||
"StartupWMClass": "Session"
|
||||
},
|
||||
"asarUnpack": "node_modules/spellchecker/vendor/hunspell_dictionaries",
|
||||
"target": ["deb", "rpm", "freebsd"],
|
||||
"target": [
|
||||
"deb",
|
||||
"rpm",
|
||||
"freebsd"
|
||||
],
|
||||
"icon": "build/icon.icns"
|
||||
},
|
||||
"asarUnpack": [
|
||||
|
|
11
preload.js
11
preload.js
|
@ -204,13 +204,8 @@ window.nodeSetImmediate = setImmediate;
|
|||
|
||||
const Signal = require('./js/modules/signal');
|
||||
const i18n = require('./js/modules/i18n');
|
||||
const Attachments = require('./ts/attachments/attachments');
|
||||
|
||||
window.Signal = Signal.setup({
|
||||
Attachments,
|
||||
userDataPath: app.getPath('userData'),
|
||||
logger: window.log,
|
||||
});
|
||||
window.Signal = Signal.setup();
|
||||
|
||||
window.getSwarmPollingInstance = require('./ts/session/apis/snode_api/').getSwarmPollingInstance;
|
||||
|
||||
|
@ -226,11 +221,7 @@ setInterval(() => {
|
|||
window.nodeSetImmediate(() => {});
|
||||
}, 1000);
|
||||
|
||||
const { autoOrientImage } = require('./js/modules/auto_orient_image');
|
||||
|
||||
window.autoOrientImage = autoOrientImage;
|
||||
window.loadImage = require('blueimp-load-image');
|
||||
window.dataURLToBlobSync = require('blueimp-canvas-to-blob');
|
||||
window.filesize = require('filesize');
|
||||
window.profileImages = require('./app/profile_images');
|
||||
|
||||
|
|
|
@ -61,10 +61,6 @@
|
|||
z-index: 1;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.input-file {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.expired {
|
||||
|
|
|
@ -11,7 +11,7 @@ import { decryptAttachmentBuffer, encryptAttachmentBuffer } from '../../ts/types
|
|||
const PATH = 'attachments.noindex';
|
||||
|
||||
export const getAllAttachments = async (userDataPath: string) => {
|
||||
const dir = exports.getPath(userDataPath);
|
||||
const dir = getPath(userDataPath);
|
||||
const pattern = path.join(dir, '**', '*');
|
||||
|
||||
const files = await pify(glob)(pattern, { nodir: true });
|
||||
|
@ -31,7 +31,7 @@ export const ensureDirectory = async (userDataPath: string) => {
|
|||
if (!isString(userDataPath)) {
|
||||
throw new TypeError("'userDataPath' must be a string");
|
||||
}
|
||||
await fse.ensureDir(exports.getPath(userDataPath));
|
||||
await fse.ensureDir(getPath(userDataPath));
|
||||
};
|
||||
|
||||
// createReader :: AttachmentsPath ->
|
||||
|
@ -72,9 +72,9 @@ export const createWriterForNew = (root: string) => {
|
|||
throw new TypeError("'arrayBuffer' must be an array buffer");
|
||||
}
|
||||
|
||||
const name = exports.createName();
|
||||
const relativePath = exports.getRelativePath(name);
|
||||
return exports.createWriterForExisting(root)({
|
||||
const name = createName();
|
||||
const relativePath = getRelativePath(name);
|
||||
return createWriterForExisting(root)({
|
||||
data: arrayBuffer,
|
||||
path: relativePath,
|
||||
});
|
||||
|
@ -84,7 +84,7 @@ export const createWriterForNew = (root: string) => {
|
|||
// createWriter :: AttachmentsPath ->
|
||||
// { data: ArrayBuffer, path: RelativePath } ->
|
||||
// IO (Promise RelativePath)
|
||||
export const createWriterForExisting = (root: any) => {
|
||||
export const createWriterForExisting = (root: string) => {
|
||||
if (!isString(root)) {
|
||||
throw new TypeError("'root' must be a path");
|
||||
}
|
||||
|
@ -120,12 +120,12 @@ export const createWriterForExisting = (root: any) => {
|
|||
// createDeleter :: AttachmentsPath ->
|
||||
// RelativePath ->
|
||||
// IO Unit
|
||||
export const createDeleter = (root: any) => {
|
||||
export const createDeleter = (root: string) => {
|
||||
if (!isString(root)) {
|
||||
throw new TypeError("'root' must be a path");
|
||||
}
|
||||
|
||||
return async (relativePath: any) => {
|
||||
return async (relativePath: string) => {
|
||||
if (!isString(relativePath)) {
|
||||
throw new TypeError("'relativePath' must be a string");
|
||||
}
|
||||
|
@ -139,8 +139,14 @@ export const createDeleter = (root: any) => {
|
|||
};
|
||||
};
|
||||
|
||||
export const deleteAll = async ({ userDataPath, attachments }: any) => {
|
||||
const deleteFromDisk = exports.createDeleter(exports.getPath(userDataPath));
|
||||
export const deleteAll = async ({
|
||||
userDataPath,
|
||||
attachments,
|
||||
}: {
|
||||
userDataPath: string;
|
||||
attachments: any;
|
||||
}) => {
|
||||
const deleteFromDisk = createDeleter(getPath(userDataPath));
|
||||
|
||||
// tslint:disable-next-line: one-variable-per-declaration
|
||||
for (let index = 0, max = attachments.length; index < max; index += 1) {
|
||||
|
@ -160,7 +166,7 @@ export const createName = () => {
|
|||
};
|
||||
|
||||
// getRelativePath :: String -> Path
|
||||
export const getRelativePath = (name: any) => {
|
||||
export const getRelativePath = (name: string) => {
|
||||
if (!isString(name)) {
|
||||
throw new TypeError("'name' must be a string");
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
declare module 'blueimp-canvas-to-blob';
|
|
@ -27,6 +27,8 @@ import { StateType } from '../state/reducer';
|
|||
import { makeLookup } from '../util';
|
||||
import { SessionMainPanel } from './SessionMainPanel';
|
||||
import { createStore } from '../state/createStore';
|
||||
import { remote } from 'electron';
|
||||
import { initializeAttachmentLogic } from '../types/MessageAttachment';
|
||||
|
||||
// Workaround: A react component's required properties are filtering up through connect()
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
|
||||
|
@ -75,6 +77,9 @@ export class SessionInboxView extends React.Component<any, State> {
|
|||
}
|
||||
|
||||
private setupLeftPane() {
|
||||
const userDataPath = remote.app.getPath('userData');
|
||||
|
||||
initializeAttachmentLogic(userDataPath);
|
||||
// Here we set up a full redux store with initial state for our LeftPane Root
|
||||
const conversations = getConversationController()
|
||||
.getConversations()
|
||||
|
|
|
@ -6,7 +6,7 @@ import {
|
|||
AttachmentType,
|
||||
AttachmentTypeWithPath,
|
||||
getAlt,
|
||||
getImageDimensions,
|
||||
getImageDimensionsInAttachment,
|
||||
getThumbnailUrl,
|
||||
isVideoAttachment,
|
||||
} from '../../types/Attachment';
|
||||
|
@ -33,7 +33,7 @@ export const ImageGrid = (props: Props) => {
|
|||
}
|
||||
|
||||
if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) {
|
||||
const { height, width } = getImageDimensions(attachments[0]);
|
||||
const { height, width } = getImageDimensionsInAttachment(attachments[0]);
|
||||
|
||||
return (
|
||||
<div className={classNames('module-image-grid', 'module-image-grid--one-image')}>
|
||||
|
|
|
@ -19,7 +19,6 @@ import { InConversationCallContainer } from '../calling/InConversationCallContai
|
|||
import { SplitViewContainer } from '../SplitViewContainer';
|
||||
import { LightboxGallery, MediaItemType } from '../lightbox/LightboxGallery';
|
||||
import { getPubkeysInPublicConversation } from '../../data/data';
|
||||
import { Constants } from '../../session';
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { ToastUtils, UserUtils } from '../../session/utils';
|
||||
import {
|
||||
|
@ -40,6 +39,10 @@ import { MessageView } from '../MainViewController';
|
|||
import { ConversationHeaderWithDetails } from './ConversationHeader';
|
||||
import { MessageDetail } from './message/message-item/MessageDetail';
|
||||
import { SessionRightPanelWithDetails } from './SessionRightPanel';
|
||||
import { autoOrientImage } from '../../types/attachments/migrations';
|
||||
import { makeVideoScreenshot } from '../../types/attachments/VisualAttachment';
|
||||
import { blobToArrayBuffer } from 'blob-util';
|
||||
import { MAX_ATTACHMENT_FILESIZE_BYTES } from '../../session/constants';
|
||||
// tslint:disable: jsx-curly-spacing
|
||||
|
||||
interface State {
|
||||
|
@ -334,19 +337,17 @@ export class SessionConversation extends React.Component<Props, State> {
|
|||
ToastUtils.pushCannotMixError();
|
||||
return;
|
||||
}
|
||||
const { VisualAttachment } = window.Signal.Types;
|
||||
|
||||
const renderVideoPreview = async () => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
try {
|
||||
const type = 'image/png';
|
||||
|
||||
const thumbnail = await VisualAttachment.makeVideoScreenshot({
|
||||
const thumbnail = await makeVideoScreenshot({
|
||||
objectUrl,
|
||||
contentType: type,
|
||||
logger: window.log,
|
||||
});
|
||||
const data = await VisualAttachment.blobToArrayBuffer(thumbnail);
|
||||
const data = await blobToArrayBuffer(thumbnail);
|
||||
const url = window.Signal.Util.arrayBufferToObjectURL({
|
||||
data,
|
||||
type,
|
||||
|
@ -392,7 +393,7 @@ export class SessionConversation extends React.Component<Props, State> {
|
|||
return;
|
||||
}
|
||||
|
||||
const url = await window.autoOrientImage(file);
|
||||
const url = await autoOrientImage(file);
|
||||
|
||||
this.addAttachments([
|
||||
{
|
||||
|
@ -410,15 +411,16 @@ export class SessionConversation extends React.Component<Props, State> {
|
|||
};
|
||||
|
||||
let blob = null;
|
||||
console.warn('typeof file: ', typeof file);
|
||||
|
||||
try {
|
||||
blob = await AttachmentUtil.autoScale({
|
||||
contentType,
|
||||
file,
|
||||
blob: file,
|
||||
});
|
||||
|
||||
if (blob.file.size >= Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES) {
|
||||
ToastUtils.pushFileSizeErrorAsByte(Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES);
|
||||
if (blob.blob.size >= MAX_ATTACHMENT_FILESIZE_BYTES) {
|
||||
ToastUtils.pushFileSizeErrorAsByte(MAX_ATTACHMENT_FILESIZE_BYTES);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
@ -8,6 +8,7 @@ import MicRecorder from 'mic-recorder-to-mp3';
|
|||
import styled from 'styled-components';
|
||||
import { Constants } from '../../session';
|
||||
import { ToastUtils } from '../../session/utils';
|
||||
import { MAX_ATTACHMENT_FILESIZE_BYTES } from '../../session/constants';
|
||||
|
||||
interface Props {
|
||||
onExitVoiceNoteView: () => void;
|
||||
|
@ -266,8 +267,8 @@ export class SessionRecording extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
// Is the audio file > attachment filesize limit
|
||||
if (this.audioBlobMp3.size > Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES) {
|
||||
ToastUtils.pushFileSizeErrorAsByte(Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES);
|
||||
if (this.audioBlobMp3.size > MAX_ATTACHMENT_FILESIZE_BYTES) {
|
||||
ToastUtils.pushFileSizeErrorAsByte(MAX_ATTACHMENT_FILESIZE_BYTES);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ import { SessionDropdown } from '../basic/SessionDropdown';
|
|||
import { SpacerLG } from '../basic/Text';
|
||||
import { MediaItemType } from '../lightbox/LightboxGallery';
|
||||
import { MediaGallery } from './media-gallery/MediaGallery';
|
||||
import { getAbsoluteAttachmentPath } from '../../types/MessageAttachment';
|
||||
|
||||
async function getMediaGalleryProps(
|
||||
conversationId: string
|
||||
|
@ -58,10 +59,8 @@ async function getMediaGalleryProps(
|
|||
const { thumbnail } = attachment;
|
||||
|
||||
const mediaItem: MediaItemType = {
|
||||
objectURL: window.Signal.Migrations.getAbsoluteAttachmentPath(attachment.path),
|
||||
thumbnailObjectUrl: thumbnail
|
||||
? window.Signal.Migrations.getAbsoluteAttachmentPath(thumbnail.path)
|
||||
: null,
|
||||
objectURL: getAbsoluteAttachmentPath(attachment.path),
|
||||
thumbnailObjectUrl: thumbnail ? getAbsoluteAttachmentPath(thumbnail.path) : undefined,
|
||||
contentType: attachment.contentType || '',
|
||||
index,
|
||||
messageTimestamp: timestamp || serverTimestamp || received_at || 0,
|
||||
|
|
|
@ -6,6 +6,7 @@ import { arrayBufferFromFile } from '../../types/Attachment';
|
|||
import { AttachmentUtil, LinkPreviewUtil } from '../../util';
|
||||
import { fetchLinkPreviewImage } from '../../util/linkPreviewFetch';
|
||||
import { StagedLinkPreview } from './StagedLinkPreview';
|
||||
import { getImageDimensions } from '../../types/attachments/VisualAttachment';
|
||||
|
||||
export interface StagedLinkPreviewProps extends StagedLinkPreviewData {
|
||||
onClose: (url: string) => void;
|
||||
|
@ -65,26 +66,25 @@ export const getPreview = async (
|
|||
const withBlob = await AttachmentUtil.autoScale(
|
||||
{
|
||||
contentType: fullSizeImage.contentType,
|
||||
file: new Blob([fullSizeImage.data], {
|
||||
blob: new Blob([fullSizeImage.data], {
|
||||
type: fullSizeImage.contentType,
|
||||
}),
|
||||
},
|
||||
{ maxSize: 100 * 1000 } // this is a preview image. No need for it to be crazy big. 100k is big enough
|
||||
);
|
||||
|
||||
const data = await arrayBufferFromFile(withBlob.file);
|
||||
objectUrl = URL.createObjectURL(withBlob.file);
|
||||
const data = await arrayBufferFromFile(withBlob.blob);
|
||||
objectUrl = URL.createObjectURL(withBlob.blob);
|
||||
|
||||
const dimensions = await window.Signal.Types.VisualAttachment.getImageDimensions({
|
||||
const dimensions = await getImageDimensions({
|
||||
objectUrl,
|
||||
logger: window.log,
|
||||
});
|
||||
|
||||
image = {
|
||||
data,
|
||||
size: data.byteLength,
|
||||
...dimensions,
|
||||
contentType: withBlob.file.type,
|
||||
contentType: withBlob.blob.type,
|
||||
};
|
||||
} catch (error) {
|
||||
// We still want to show the preview if we failed to get an image
|
||||
|
|
|
@ -28,7 +28,6 @@ import {
|
|||
import { AttachmentType } from '../../../types/Attachment';
|
||||
import { connect } from 'react-redux';
|
||||
import { showLinkSharingConfirmationModalDialog } from '../../../interactions/conversationInteractions';
|
||||
import { Constants } from '../../../session';
|
||||
import { getConversationController } from '../../../session/conversations';
|
||||
import { ToastUtils } from '../../../session/utils';
|
||||
import { ReduxConversationType } from '../../../state/ducks/conversations';
|
||||
|
@ -46,6 +45,7 @@ import { AttachmentUtil } from '../../../util';
|
|||
import { Flex } from '../../basic/Flex';
|
||||
import { CaptionEditor } from '../../CaptionEditor';
|
||||
import { StagedAttachmentList } from '../StagedAttachmentList';
|
||||
import { processNewAttachment } from '../../../types/MessageAttachment';
|
||||
|
||||
export interface ReplyingToMessageProps {
|
||||
convoId: string;
|
||||
|
@ -872,7 +872,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
// this function is called right before sending a message, to gather really the files behind attachments.
|
||||
private async getFiles(): Promise<Array<any>> {
|
||||
private async getFiles(): Promise<Array<StagedAttachmentType & { flags?: number }>> {
|
||||
const { stagedAttachments } = this.props;
|
||||
|
||||
if (_.isEmpty(stagedAttachments)) {
|
||||
|
@ -880,11 +880,7 @@ class CompositionBoxInner extends React.Component<Props, State> {
|
|||
}
|
||||
// scale them down
|
||||
const files = await Promise.all(
|
||||
stagedAttachments.map(attachment =>
|
||||
AttachmentUtil.getFile(attachment, {
|
||||
maxSize: Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES,
|
||||
})
|
||||
)
|
||||
stagedAttachments.map(attachment => AttachmentUtil.getFileAndStoreLocally(attachment))
|
||||
);
|
||||
window.inboxStore?.dispatch(
|
||||
removeAllStagedAttachmentsInConversation({
|
||||
|
@ -899,13 +895,14 @@ class CompositionBoxInner extends React.Component<Props, State> {
|
|||
return;
|
||||
}
|
||||
|
||||
const savedAudioFile = await window.Signal.Migrations.processNewAttachment({
|
||||
const savedAudioFile = await processNewAttachment({
|
||||
data: await audioBlob.arrayBuffer(),
|
||||
isRaw: true,
|
||||
url: `session-audio-message-${Date.now()}`,
|
||||
contentType: MIME.AUDIO_MP3,
|
||||
});
|
||||
// { ...savedAudioFile, path: savedAudioFile.path },
|
||||
const audioAttachment: StagedAttachmentType = {
|
||||
file: { ...savedAudioFile, path: savedAudioFile.path },
|
||||
file: new File([], 'session-audio-message'), // this is just to emulate a file for the staged attachment type of that audio file
|
||||
contentType: MIME.AUDIO_MP3,
|
||||
size: audioBlob.size,
|
||||
fileSize: null,
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
import {
|
||||
canDisplayImage,
|
||||
getGridDimensions,
|
||||
getImageDimensions,
|
||||
getImageDimensionsInAttachment,
|
||||
hasImage,
|
||||
hasVideoScreenshot,
|
||||
isImage,
|
||||
|
@ -226,7 +226,7 @@ function getWidth(
|
|||
const { width } = first.image;
|
||||
|
||||
if (isImageAttachment(first.image) && width && width >= MINIMUM_LINK_PREVIEW_IMAGE_WIDTH) {
|
||||
const dimensions = getImageDimensions(first.image);
|
||||
const dimensions = getImageDimensionsInAttachment(first.image);
|
||||
if (dimensions) {
|
||||
return dimensions.width;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import { SyncUtils, ToastUtils, UserUtils } from '../../session/utils';
|
|||
|
||||
import { ConversationModel, ConversationTypeEnum } from '../../models/conversation';
|
||||
|
||||
import { AttachmentUtil } from '../../util';
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { SpacerLG, SpacerMD } from '../basic/Text';
|
||||
import autoBind from 'auto-bind';
|
||||
|
@ -20,17 +19,26 @@ import { SessionSpinner } from '../basic/SessionSpinner';
|
|||
import { SessionIconButton } from '../icon';
|
||||
import { MAX_USERNAME_LENGTH } from '../registration/RegistrationStages';
|
||||
import { SessionWrapperModal } from '../SessionWrapperModal';
|
||||
import { pickFileForAvatar } from '../../types/attachments/VisualAttachment';
|
||||
|
||||
interface State {
|
||||
profileName: string;
|
||||
setProfileName: string;
|
||||
avatar: string;
|
||||
oldAvatarPath: string;
|
||||
newAvatarObjectUrl: string | null;
|
||||
mode: 'default' | 'edit' | 'qr';
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const QRView = ({ sessionID }: { sessionID: string }) => {
|
||||
return (
|
||||
<div className="qr-image">
|
||||
<QRCode value={sessionID} bgColor="#FFFFFF" fgColor="#1B1B1B" level="L" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class EditProfileDialog extends React.Component<{}, State> {
|
||||
private readonly inputEl: any;
|
||||
private readonly convo: ConversationModel;
|
||||
|
||||
constructor(props: any) {
|
||||
|
@ -43,12 +51,11 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
this.state = {
|
||||
profileName: this.convo.getProfileName() || '',
|
||||
setProfileName: this.convo.getProfileName() || '',
|
||||
avatar: this.convo.getAvatarPath() || '',
|
||||
oldAvatarPath: this.convo.getAvatarPath() || '',
|
||||
newAvatarObjectUrl: null,
|
||||
mode: 'default',
|
||||
loading: false,
|
||||
};
|
||||
|
||||
this.inputEl = React.createRef();
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
|
@ -91,7 +98,7 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
>
|
||||
<SpacerMD />
|
||||
|
||||
{viewQR && this.renderQRView(sessionID)}
|
||||
{viewQR && <QRView sessionID={sessionID} />}
|
||||
{viewDefault && this.renderDefaultView()}
|
||||
{viewEdit && this.renderEditView()}
|
||||
|
||||
|
@ -113,7 +120,7 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
buttonType={SessionButtonType.BrandOutline}
|
||||
buttonColor={SessionButtonColor.Green}
|
||||
onClick={() => {
|
||||
this.copySessionID(sessionID);
|
||||
copySessionID(sessionID);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
|
@ -142,14 +149,6 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
<div className="avatar-center-inner">
|
||||
{this.renderAvatar()}
|
||||
<div className="image-upload-section" role="button" onClick={this.fireInputEvent} />
|
||||
<input
|
||||
type="file"
|
||||
ref={this.inputEl}
|
||||
className="input-file"
|
||||
placeholder="input file"
|
||||
name="name"
|
||||
onChange={this.onFileSelected}
|
||||
/>
|
||||
<div
|
||||
className="qr-view-button"
|
||||
onClick={() => {
|
||||
|
@ -165,16 +164,15 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private fireInputEvent() {
|
||||
this.setState(
|
||||
state => ({ ...state, mode: 'edit' }),
|
||||
() => {
|
||||
const el = this.inputEl.current;
|
||||
if (el) {
|
||||
el.click();
|
||||
}
|
||||
}
|
||||
);
|
||||
private async fireInputEvent() {
|
||||
const scaledAvatarUrl = await pickFileForAvatar();
|
||||
|
||||
if (scaledAvatarUrl) {
|
||||
this.setState({
|
||||
newAvatarObjectUrl: scaledAvatarUrl,
|
||||
mode: 'edit',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private renderDefaultView() {
|
||||
|
@ -220,33 +218,13 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
);
|
||||
}
|
||||
|
||||
private renderQRView(sessionID: string) {
|
||||
const bgColor = '#FFFFFF';
|
||||
const fgColor = '#1B1B1B';
|
||||
|
||||
return (
|
||||
<div className="qr-image">
|
||||
<QRCode value={sessionID} bgColor={bgColor} fgColor={fgColor} level="L" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onFileSelected() {
|
||||
const file = this.inputEl.current.files[0];
|
||||
const url = window.URL.createObjectURL(file);
|
||||
|
||||
this.setState({
|
||||
avatar: url,
|
||||
});
|
||||
}
|
||||
|
||||
private renderAvatar() {
|
||||
const { avatar, profileName } = this.state;
|
||||
const { oldAvatarPath, newAvatarObjectUrl, profileName } = this.state;
|
||||
const userName = profileName || this.convo.id;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
forcedAvatarPath={avatar}
|
||||
forcedAvatarPath={newAvatarObjectUrl || oldAvatarPath}
|
||||
forcedName={userName}
|
||||
size={AvatarSize.XL}
|
||||
pubkey={this.convo.id}
|
||||
|
@ -256,11 +234,8 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
|
||||
private onNameEdited(event: any) {
|
||||
const newName = event.target.value.replace(window.displayNameRegex, '');
|
||||
this.setState(state => {
|
||||
return {
|
||||
...state,
|
||||
profileName: newName,
|
||||
};
|
||||
this.setState({
|
||||
profileName: newName,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -279,35 +254,23 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
}
|
||||
}
|
||||
|
||||
private copySessionID(sessionID: string) {
|
||||
window.clipboard.writeText(sessionID);
|
||||
ToastUtils.pushCopiedToClipBoard();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tidy the profile name input text and save the new profile name and avatar
|
||||
*/
|
||||
private onClickOK() {
|
||||
const newName = this.state.profileName ? this.state.profileName.trim() : '';
|
||||
const { newAvatarObjectUrl, profileName } = this.state;
|
||||
const newName = profileName ? profileName.trim() : '';
|
||||
|
||||
if (newName.length === 0 || newName.length > MAX_USERNAME_LENGTH) {
|
||||
return;
|
||||
}
|
||||
|
||||
const avatar =
|
||||
this.inputEl &&
|
||||
this.inputEl.current &&
|
||||
this.inputEl.current.files &&
|
||||
this.inputEl.current.files.length > 0
|
||||
? this.inputEl.current.files[0]
|
||||
: null;
|
||||
|
||||
this.setState(
|
||||
{
|
||||
loading: true,
|
||||
},
|
||||
async () => {
|
||||
await this.commitProfileEdits(newName, avatar);
|
||||
await commitProfileEdits(newName, newAvatarObjectUrl);
|
||||
this.setState({
|
||||
loading: false,
|
||||
|
||||
|
@ -320,60 +283,46 @@ export class EditProfileDialog extends React.Component<{}, State> {
|
|||
|
||||
private closeDialog() {
|
||||
window.removeEventListener('keyup', this.onKeyUp);
|
||||
|
||||
window.inboxStore?.dispatch(editProfileModal(null));
|
||||
}
|
||||
|
||||
private async commitProfileEdits(newName: string, avatar: any) {
|
||||
const ourNumber = UserUtils.getOurPubKeyStrFromCache();
|
||||
const conversation = await getConversationController().getOrCreateAndWait(
|
||||
ourNumber,
|
||||
ConversationTypeEnum.PRIVATE
|
||||
);
|
||||
|
||||
if (avatar) {
|
||||
const data = await AttachmentUtil.readFile({ file: avatar });
|
||||
// Ensure that this file is either small enough or is resized to meet our
|
||||
// requirements for attachments
|
||||
try {
|
||||
const withBlob = await AttachmentUtil.autoScale(
|
||||
{
|
||||
contentType: avatar.type,
|
||||
file: new Blob([data.data], {
|
||||
type: avatar.contentType,
|
||||
}),
|
||||
},
|
||||
{
|
||||
maxSide: 640,
|
||||
maxSize: 1000 * 1024,
|
||||
}
|
||||
);
|
||||
const dataResized = await window.Signal.Types.Attachment.arrayBufferFromFile(withBlob.file);
|
||||
|
||||
// For simplicity we use the same attachment pointer that would send to
|
||||
// others, which means we need to wait for the database response.
|
||||
// To avoid the wait, we create a temporary url for the local image
|
||||
// and use it until we the the response from the server
|
||||
// const tempUrl = window.URL.createObjectURL(avatar);
|
||||
// await conversation.setLokiProfile({ displayName: newName });
|
||||
// conversation.set('avatar', tempUrl);
|
||||
|
||||
await uploadOurAvatar(dataResized);
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'showEditProfileDialog Error ensuring that image is properly sized:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// do not update the avatar if it did not change
|
||||
await conversation.setLokiProfile({
|
||||
displayName: newName,
|
||||
});
|
||||
// might be good to not trigger a sync if the name did not change
|
||||
await conversation.commit();
|
||||
UserUtils.setLastProfileUpdateTimestamp(Date.now());
|
||||
await SyncUtils.forceSyncConfigurationNowIfNeeded(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function commitProfileEdits(newName: string, scaledAvatarUrl: string | null) {
|
||||
const ourNumber = UserUtils.getOurPubKeyStrFromCache();
|
||||
const conversation = await getConversationController().getOrCreateAndWait(
|
||||
ourNumber,
|
||||
ConversationTypeEnum.PRIVATE
|
||||
);
|
||||
|
||||
if (scaledAvatarUrl?.length) {
|
||||
try {
|
||||
const blobContent = await (await fetch(scaledAvatarUrl)).blob();
|
||||
if (!blobContent || !blobContent.size) {
|
||||
throw new Error('Failed to fetch blob content from scaled avatar');
|
||||
}
|
||||
await uploadOurAvatar(await blobContent.arrayBuffer());
|
||||
} catch (error) {
|
||||
if (error.message && error.message.length) {
|
||||
ToastUtils.pushToastError('edit-profile', error.message);
|
||||
}
|
||||
window.log.error(
|
||||
'showEditProfileDialog Error ensuring that image is properly sized:',
|
||||
error && error.stack ? error.stack : error
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// do not update the avatar if it did not change
|
||||
await conversation.setLokiProfile({
|
||||
displayName: newName,
|
||||
});
|
||||
// might be good to not trigger a sync if the name did not change
|
||||
await conversation.commit();
|
||||
UserUtils.setLastProfileUpdateTimestamp(Date.now());
|
||||
await SyncUtils.forceSyncConfigurationNowIfNeeded(true);
|
||||
}
|
||||
|
||||
function copySessionID(sessionID: string) {
|
||||
window.clipboard.writeText(sessionID);
|
||||
ToastUtils.pushCopiedToClipBoard();
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ import React from 'react';
|
|||
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { ToastUtils, UserUtils } from '../../session/utils';
|
||||
import { initiateGroupUpdate } from '../../session/group';
|
||||
import { ConversationTypeEnum } from '../../models/conversation';
|
||||
import { getCompleteUrlForV2ConvoId } from '../../interactions/conversationInteractions';
|
||||
import _ from 'lodash';
|
||||
|
@ -18,6 +17,7 @@ import { SessionWrapperModal } from '../SessionWrapperModal';
|
|||
import { getPrivateContactsPubkeys } from '../../state/selectors/conversations';
|
||||
import { useConversationPropsById } from '../../hooks/useParamSelector';
|
||||
import { useSet } from '../../hooks/useSet';
|
||||
import { initiateClosedGroupUpdate } from '../../session/group/closed-group';
|
||||
|
||||
type Props = {
|
||||
conversationId: string;
|
||||
|
@ -85,7 +85,7 @@ const submitForClosedGroup = async (convoId: string, pubkeys: Array<string>) =>
|
|||
const groupId = convo.get('id');
|
||||
const groupName = convo.get('name');
|
||||
|
||||
await initiateGroupUpdate(groupId, groupName || window.i18n('unknown'), uniqMembers, undefined);
|
||||
await initiateClosedGroupUpdate(groupId, groupName || window.i18n('unknown'), uniqMembers);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -14,8 +14,8 @@ import { useConversationPropsById } from '../../hooks/useParamSelector';
|
|||
import useKey from 'react-use/lib/useKey';
|
||||
import { useWeAreAdmin } from '../../hooks/useWeAreAdmin';
|
||||
import { useSet } from '../../hooks/useSet';
|
||||
import { ClosedGroup } from '../../session';
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { initiateClosedGroupUpdate } from '../../session/group/closed-group';
|
||||
|
||||
type Props = {
|
||||
conversationId: string;
|
||||
|
@ -164,15 +164,7 @@ async function onSubmit(convoId: string, membersAfterUpdate: Array<string>) {
|
|||
memberAfterUpdate => !_.includes(membersToRemove, memberAfterUpdate)
|
||||
);
|
||||
|
||||
const avatarPath = convoProps.avatarPath || '';
|
||||
const groupName = convoProps.name;
|
||||
|
||||
void ClosedGroup.initiateGroupUpdate(
|
||||
convoId,
|
||||
groupName || 'Unknown',
|
||||
filteredMembers,
|
||||
avatarPath
|
||||
);
|
||||
void initiateClosedGroupUpdate(convoId, convoProps.name || 'Unknown', filteredMembers);
|
||||
}
|
||||
|
||||
export const UpdateGroupMembersDialog = (props: Props) => {
|
||||
|
|
|
@ -7,9 +7,11 @@ import { updateGroupNameModal } from '../../state/ducks/modalDialog';
|
|||
import autoBind from 'auto-bind';
|
||||
import { ConversationModel } from '../../models/conversation';
|
||||
import { getConversationController } from '../../session/conversations';
|
||||
import { ClosedGroup } from '../../session';
|
||||
import { SessionWrapperModal } from '../SessionWrapperModal';
|
||||
import { SessionButton, SessionButtonColor } from '../basic/SessionButton';
|
||||
import { initiateOpenGroupUpdate } from '../../session/group/open-group';
|
||||
import { initiateClosedGroupUpdate } from '../../session/group/closed-group';
|
||||
import { pickFileForAvatar } from '../../types/attachments/VisualAttachment';
|
||||
|
||||
type Props = {
|
||||
conversationId: string;
|
||||
|
@ -19,11 +21,11 @@ interface State {
|
|||
groupName: string | undefined;
|
||||
errorDisplayed: boolean;
|
||||
errorMessage: string;
|
||||
avatar: string | null;
|
||||
oldAvatarPath: string | null;
|
||||
newAvatarObjecturl: string | null;
|
||||
}
|
||||
|
||||
export class UpdateGroupNameDialog extends React.Component<Props, State> {
|
||||
private readonly inputEl: any;
|
||||
private readonly convo: ConversationModel;
|
||||
|
||||
constructor(props: Props) {
|
||||
|
@ -36,9 +38,9 @@ export class UpdateGroupNameDialog extends React.Component<Props, State> {
|
|||
groupName: this.convo.getName(),
|
||||
errorDisplayed: false,
|
||||
errorMessage: 'placeholder',
|
||||
avatar: this.convo.getAvatarPath(),
|
||||
oldAvatarPath: this.convo.getAvatarPath(),
|
||||
newAvatarObjecturl: null,
|
||||
};
|
||||
this.inputEl = React.createRef();
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
|
@ -50,24 +52,24 @@ export class UpdateGroupNameDialog extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
public onClickOK() {
|
||||
if (!this.state.groupName?.trim()) {
|
||||
const { groupName, newAvatarObjecturl, oldAvatarPath } = this.state;
|
||||
const trimmedGroupName = groupName?.trim();
|
||||
if (!trimmedGroupName) {
|
||||
this.onShowError(window.i18n('emptyGroupNameError'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const newAvatarPath =
|
||||
this?.inputEl?.current?.files?.length > 0 ? this.inputEl.current.files[0] : null; // otherwise use the current avatar
|
||||
if (trimmedGroupName !== this.convo.getName() || newAvatarObjecturl !== oldAvatarPath) {
|
||||
if (this.convo.isPublic()) {
|
||||
void initiateOpenGroupUpdate(this.convo.id, trimmedGroupName, {
|
||||
objectUrl: newAvatarObjecturl,
|
||||
});
|
||||
} else {
|
||||
const members = this.convo.get('members') || [];
|
||||
|
||||
if (this.state.groupName !== this.convo.getName() || newAvatarPath !== this.state.avatar) {
|
||||
const members = this.convo.get('members') || [];
|
||||
|
||||
void ClosedGroup.initiateGroupUpdate(
|
||||
this.convo.id,
|
||||
this.state.groupName,
|
||||
members,
|
||||
newAvatarPath
|
||||
);
|
||||
void initiateClosedGroupUpdate(this.convo.id, trimmedGroupName, members);
|
||||
}
|
||||
}
|
||||
|
||||
this.closeDialog();
|
||||
|
@ -183,6 +185,8 @@ export class UpdateGroupNameDialog extends React.Component<Props, State> {
|
|||
const isPublic = this.convo.isPublic();
|
||||
const pubkey = this.convo.id;
|
||||
|
||||
const { newAvatarObjecturl, oldAvatarPath } = this.state;
|
||||
|
||||
if (!isPublic) {
|
||||
return undefined;
|
||||
}
|
||||
|
@ -191,36 +195,21 @@ export class UpdateGroupNameDialog extends React.Component<Props, State> {
|
|||
return (
|
||||
<div className="avatar-center">
|
||||
<div className="avatar-center-inner">
|
||||
<Avatar forcedAvatarPath={this.state.avatar || ''} size={AvatarSize.XL} pubkey={pubkey} />
|
||||
<div
|
||||
className="image-upload-section"
|
||||
role="button"
|
||||
onClick={() => {
|
||||
const el = this.inputEl.current;
|
||||
if (el) {
|
||||
el.click();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={this.inputEl}
|
||||
className="input-file"
|
||||
placeholder="input file"
|
||||
name="name"
|
||||
onChange={this.onFileSelected}
|
||||
<Avatar
|
||||
forcedAvatarPath={newAvatarObjecturl || oldAvatarPath}
|
||||
size={AvatarSize.XL}
|
||||
pubkey={pubkey}
|
||||
/>
|
||||
<div className="image-upload-section" role="button" onClick={this.fireInputEvent} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onFileSelected() {
|
||||
const file = this.inputEl.current.files[0];
|
||||
const url = window.URL.createObjectURL(file);
|
||||
|
||||
this.setState({
|
||||
avatar: url,
|
||||
});
|
||||
private async fireInputEvent() {
|
||||
const scaledObjectUrl = await pickFileForAvatar();
|
||||
if (scaledObjectUrl) {
|
||||
this.setState({ newAvatarObjecturl: scaledObjectUrl });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -234,6 +234,7 @@ const doAppStartUp = () => {
|
|||
// init the messageQueue. In the constructor, we add all not send messages
|
||||
// this call does nothing except calling the constructor, which will continue sending message in the pipeline
|
||||
void getMessageQueue().processAllPending();
|
||||
|
||||
void setupTheme();
|
||||
|
||||
// keep that one to make sure our users upgrade to new sessionIDS
|
||||
|
|
|
@ -38,6 +38,9 @@ import { forceSyncConfigurationNowIfNeeded } from '../session/utils/syncUtils';
|
|||
import { SessionButtonColor } from '../components/basic/SessionButton';
|
||||
import { getCallMediaPermissionsSettings } from '../components/settings/SessionSettings';
|
||||
import { perfEnd, perfStart } from '../session/utils/Performance';
|
||||
import { processNewAttachment } from '../types/MessageAttachment';
|
||||
import { urlToBlob } from '../types/attachments/VisualAttachment';
|
||||
import { MIME } from '../types';
|
||||
|
||||
export const getCompleteUrlForV2ConvoId = async (convoId: string) => {
|
||||
if (convoId.match(openGroupV2ConversationIdRegex)) {
|
||||
|
@ -348,8 +351,8 @@ export async function uploadOurAvatar(newAvatarDecrypted?: ArrayBuffer) {
|
|||
window.log.warn('Could not decrypt avatar stored locally..');
|
||||
return;
|
||||
}
|
||||
const response = await fetch(decryptedAvatarUrl);
|
||||
const blob = await response.blob();
|
||||
const blob = await urlToBlob(decryptedAvatarUrl);
|
||||
|
||||
decryptedAvatarData = await blob.arrayBuffer();
|
||||
}
|
||||
|
||||
|
@ -374,10 +377,11 @@ export async function uploadOurAvatar(newAvatarDecrypted?: ArrayBuffer) {
|
|||
ourConvo.set('avatarPointer', fileUrl);
|
||||
|
||||
// this encrypts and save the new avatar and returns a new attachment path
|
||||
const upgraded = await window.Signal.Migrations.processNewAttachment({
|
||||
const upgraded = await processNewAttachment({
|
||||
isRaw: true,
|
||||
data: decryptedAvatarData,
|
||||
url: fileUrl,
|
||||
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case.
|
||||
// url: fileUrl,
|
||||
});
|
||||
// Replace our temporary image with the attachment pointer from the server:
|
||||
ourConvo.set('avatar', null);
|
||||
|
|
|
@ -6,7 +6,7 @@ import { ClosedGroupVisibleMessage } from '../session/messages/outgoing/visibleM
|
|||
import { PubKey } from '../session/types';
|
||||
import { UserUtils } from '../session/utils';
|
||||
import { BlockedNumberController } from '../util';
|
||||
import { leaveClosedGroup } from '../session/group';
|
||||
import { leaveClosedGroup } from '../session/group/closed-group';
|
||||
import { SignalService } from '../protobuf';
|
||||
import { MessageModel } from './message';
|
||||
import { MessageAttributesOptionals, MessageModelType } from './messageType';
|
||||
|
@ -54,6 +54,11 @@ import {
|
|||
SendMessageType,
|
||||
} from '../components/conversation/composition/CompositionBox';
|
||||
import { SettingsKey } from '../data/settings-key';
|
||||
import {
|
||||
deleteExternalFilesOfConversation,
|
||||
getAbsoluteAttachmentPath,
|
||||
loadAttachmentData,
|
||||
} from '../types/MessageAttachment';
|
||||
|
||||
export enum ConversationTypeEnum {
|
||||
GROUP = 'group',
|
||||
|
@ -286,10 +291,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
}
|
||||
|
||||
public async cleanup() {
|
||||
const { deleteAttachmentData } = window.Signal.Migrations;
|
||||
await window.Signal.Types.Conversation.deleteExternalFiles(this.attributes, {
|
||||
deleteAttachmentData,
|
||||
});
|
||||
await deleteExternalFilesOfConversation(this.attributes);
|
||||
window.profileImages.removeImage(this.id);
|
||||
}
|
||||
|
||||
|
@ -521,8 +523,6 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
}
|
||||
|
||||
public async getQuoteAttachment(attachments: any, preview: any) {
|
||||
const { loadAttachmentData, getAbsoluteAttachmentPath } = window.Signal.Migrations;
|
||||
|
||||
if (attachments && attachments.length) {
|
||||
return Promise.all(
|
||||
attachments
|
||||
|
@ -722,15 +722,12 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
'with timestamp',
|
||||
now
|
||||
);
|
||||
// be sure an empty quote is marked as undefined rather than being empty
|
||||
// otherwise upgradeMessageSchema() will return an object with an empty array
|
||||
// and this.get('quote') will be true, even if there is no quote.
|
||||
|
||||
const editedQuote = _.isEmpty(quote) ? undefined : quote;
|
||||
const { upgradeMessageSchema } = window.Signal.Migrations;
|
||||
|
||||
const diffTimestamp = Date.now() - getLatestTimestampOffset();
|
||||
|
||||
const messageWithSchema = await upgradeMessageSchema({
|
||||
const messageObject: MessageAttributesOptionals = {
|
||||
type: 'outgoing',
|
||||
body,
|
||||
conversationId: destination,
|
||||
|
@ -742,19 +739,18 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
expireTimer,
|
||||
recipients,
|
||||
isDeleted: false,
|
||||
});
|
||||
source: UserUtils.getOurPubKeyStrFromCache(),
|
||||
};
|
||||
|
||||
if (!this.isPublic()) {
|
||||
messageWithSchema.destination = destination;
|
||||
messageObject.destination = destination;
|
||||
} else {
|
||||
// set the serverTimestamp only if this conversation is a public one.
|
||||
messageWithSchema.serverTimestamp = Date.now();
|
||||
messageObject.serverTimestamp = Date.now();
|
||||
}
|
||||
messageWithSchema.source = UserUtils.getOurPubKeyStrFromCache();
|
||||
messageWithSchema.sourceDevice = 1;
|
||||
|
||||
const attributes: MessageAttributesOptionals = {
|
||||
...messageWithSchema,
|
||||
...messageObject,
|
||||
groupInvitation,
|
||||
conversationId: this.id,
|
||||
destination: isPrivate ? destination : undefined,
|
||||
|
@ -1356,9 +1352,7 @@ export class ConversationModel extends Backbone.Model<ConversationAttributes> {
|
|||
}
|
||||
|
||||
if (typeof avatar?.path === 'string') {
|
||||
const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
|
||||
|
||||
return getAbsoluteAttachmentPath(avatar.path) as string;
|
||||
return getAbsoluteAttachmentPath(avatar.path);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -49,9 +49,16 @@ import { OpenGroupVisibleMessage } from '../session/messages/outgoing/visibleMes
|
|||
import { getV2OpenGroupRoom } from '../data/opengroups';
|
||||
import { isUsFromCache } from '../session/utils/User';
|
||||
import { perfEnd, perfStart } from '../session/utils/Performance';
|
||||
import { AttachmentTypeWithPath } from '../types/Attachment';
|
||||
import { AttachmentTypeWithPath, isVoiceMessage } from '../types/Attachment';
|
||||
import _ from 'lodash';
|
||||
import { SettingsKey } from '../data/settings-key';
|
||||
import {
|
||||
deleteExternalMessageFiles,
|
||||
getAbsoluteAttachmentPath,
|
||||
loadAttachmentData,
|
||||
loadPreviewData,
|
||||
loadQuoteData,
|
||||
} from '../types/MessageAttachment';
|
||||
// tslint:disable: cyclomatic-complexity
|
||||
|
||||
/**
|
||||
|
@ -212,7 +219,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
}
|
||||
|
||||
public async cleanup() {
|
||||
await window.Signal.Migrations.deleteExternalMessageFiles(this.attributes);
|
||||
await deleteExternalMessageFiles(this.attributes);
|
||||
}
|
||||
|
||||
public getPropsForTimerNotification(): PropsForExpirationTimer | null {
|
||||
|
@ -487,10 +494,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
|
||||
public processQuoteAttachment(attachment: any) {
|
||||
const { thumbnail } = attachment;
|
||||
const path =
|
||||
thumbnail &&
|
||||
thumbnail.path &&
|
||||
window.Signal.Migrations.getAbsoluteAttachmentPath(thumbnail.path);
|
||||
const path = thumbnail && thumbnail.path && getAbsoluteAttachmentPath(thumbnail.path);
|
||||
const objectUrl = thumbnail && thumbnail.objectUrl;
|
||||
|
||||
const thumbnailWithObjectUrl =
|
||||
|
@ -502,7 +506,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
});
|
||||
|
||||
return Object.assign({}, attachment, {
|
||||
isVoiceMessage: window.Signal.Types.Attachment.isVoiceMessage(attachment),
|
||||
isVoiceMessage: isVoiceMessage(attachment),
|
||||
thumbnail: thumbnailWithObjectUrl,
|
||||
});
|
||||
// tslint:enable: prefer-object-spread
|
||||
|
@ -608,7 +612,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
caption,
|
||||
} = attachment;
|
||||
|
||||
const isVoiceMessage =
|
||||
const isVoiceMessageBool =
|
||||
// tslint:disable-next-line: no-bitwise
|
||||
Boolean(flags && flags & SignalService.AttachmentPointer.Flags.VOICE_MESSAGE) || false;
|
||||
return {
|
||||
|
@ -621,19 +625,19 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
path,
|
||||
fileName,
|
||||
fileSize: size ? filesize(size) : null,
|
||||
isVoiceMessage,
|
||||
isVoiceMessage: isVoiceMessageBool,
|
||||
pending: Boolean(pending),
|
||||
url: path ? window.Signal.Migrations.getAbsoluteAttachmentPath(path) : null,
|
||||
url: path ? getAbsoluteAttachmentPath(path) : '',
|
||||
screenshot: screenshot
|
||||
? {
|
||||
...screenshot,
|
||||
url: window.Signal.Migrations.getAbsoluteAttachmentPath(screenshot.path),
|
||||
url: getAbsoluteAttachmentPath(screenshot.path),
|
||||
}
|
||||
: null,
|
||||
thumbnail: thumbnail
|
||||
? {
|
||||
...thumbnail,
|
||||
url: window.Signal.Migrations.getAbsoluteAttachmentPath(thumbnail.path),
|
||||
url: getAbsoluteAttachmentPath(thumbnail.path),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
@ -707,13 +711,13 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
// This way we don't upload duplicated data.
|
||||
|
||||
const attachmentsWithData = await Promise.all(
|
||||
(this.get('attachments') || []).map(window.Signal.Migrations.loadAttachmentData)
|
||||
(this.get('attachments') || []).map(loadAttachmentData)
|
||||
);
|
||||
const body = this.get('body');
|
||||
const finalAttachments = attachmentsWithData as Array<any>;
|
||||
const finalAttachments = attachmentsWithData;
|
||||
|
||||
const quoteWithData = await window.Signal.Migrations.loadQuoteData(this.get('quote'));
|
||||
const previewWithData = await window.Signal.Migrations.loadPreviewData(this.get('preview'));
|
||||
const quoteWithData = await loadQuoteData(this.get('quote'));
|
||||
const previewWithData = await loadPreviewData(this.get('preview'));
|
||||
|
||||
const conversation = this.getConversation();
|
||||
|
||||
|
@ -741,7 +745,7 @@ export class MessageModel extends Backbone.Model<MessageAttributes> {
|
|||
linkPreviewPromise,
|
||||
quotePromise,
|
||||
]);
|
||||
|
||||
window.log.info(`Upload of message data for message ${this.idForLogging()} is finished.`);
|
||||
return {
|
||||
body,
|
||||
attachments,
|
||||
|
|
|
@ -12,7 +12,14 @@ import { OpenGroupRequestCommonType } from '../session/apis/open_group_api/openg
|
|||
import { FSv2 } from '../session/apis/file_server_api';
|
||||
import { getUnpaddedAttachment } from '../session/crypto/BufferPadding';
|
||||
|
||||
export async function downloadAttachment(attachment: any) {
|
||||
export async function downloadAttachment(attachment: {
|
||||
url: string;
|
||||
id?: string;
|
||||
isRaw?: boolean;
|
||||
key?: string;
|
||||
digest?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const asURL = new URL(attachment.url);
|
||||
const serverUrl = asURL.origin;
|
||||
|
||||
|
@ -49,13 +56,16 @@ export async function downloadAttachment(attachment: any) {
|
|||
if (!key || !digest) {
|
||||
throw new Error('Attachment is not raw but we do not have a key to decode it');
|
||||
}
|
||||
if (!size) {
|
||||
throw new Error('Attachment expected size is 0');
|
||||
}
|
||||
|
||||
const keyBuffer = await window.callWorker('fromBase64ToArrayBuffer', key);
|
||||
const digestBuffer = await window.callWorker('fromBase64ToArrayBuffer', digest);
|
||||
|
||||
data = await window.textsecure.crypto.decryptAttachment(data, keyBuffer, digestBuffer);
|
||||
|
||||
if (!size || size !== data.byteLength) {
|
||||
if (size !== data.byteLength) {
|
||||
// we might have padding, check that all the remaining bytes are padding bytes
|
||||
// otherwise we have an error.
|
||||
const unpaddedData = getUnpaddedAttachment(data, size);
|
||||
|
@ -74,29 +84,34 @@ export async function downloadAttachment(attachment: any) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* This method should only be used when you know
|
||||
*/
|
||||
export async function downloadDataFromOpenGroupV2(
|
||||
fileUrl: string,
|
||||
roomInfos: OpenGroupRequestCommonType
|
||||
) {
|
||||
const dataUintFromUrl = await downloadFileOpenGroupV2ByUrl(fileUrl, roomInfos);
|
||||
|
||||
if (!dataUintFromUrl?.length) {
|
||||
window?.log?.error('Failed to download attachment. Length is 0');
|
||||
throw new Error(`Failed to download attachment. Length is 0 for ${fileUrl}`);
|
||||
}
|
||||
return dataUintFromUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param attachment Either the details of the attachment to download (on a per room basis), or the pathName to the file you want to get
|
||||
*/
|
||||
export async function downloadAttachmentOpenGroupV2(
|
||||
attachment:
|
||||
| {
|
||||
id: number;
|
||||
url: string;
|
||||
size: number;
|
||||
}
|
||||
| string,
|
||||
attachment: {
|
||||
id: number;
|
||||
url: string;
|
||||
size: number;
|
||||
},
|
||||
roomInfos: OpenGroupRequestCommonType
|
||||
) {
|
||||
if (typeof attachment === 'string') {
|
||||
const dataUintFromUrl = await downloadFileOpenGroupV2ByUrl(attachment, roomInfos);
|
||||
|
||||
if (!dataUintFromUrl?.length) {
|
||||
window?.log?.error('Failed to download attachment. Length is 0');
|
||||
throw new Error(`Failed to download attachment. Length is 0 for ${attachment}`);
|
||||
}
|
||||
return dataUintFromUrl;
|
||||
}
|
||||
const dataUint = await downloadFileOpenGroupV2(attachment.id, roomInfos);
|
||||
|
||||
if (!dataUint?.length) {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { EnvelopePlus } from './types';
|
|||
import { PubKey } from '../session/types';
|
||||
import { toHex } from '../session/utils/String';
|
||||
import { getConversationController } from '../session/conversations';
|
||||
import * as ClosedGroup from '../session/group';
|
||||
import * as ClosedGroup from '../session/group/closed-group';
|
||||
import { BlockedNumberController } from '../util';
|
||||
import {
|
||||
generateClosedGroupPublicKey,
|
||||
|
|
|
@ -16,6 +16,10 @@ import { getMessageBySender, getMessageBySenderAndServerTimestamp } from '../../
|
|||
import { ConversationModel, ConversationTypeEnum } from '../models/conversation';
|
||||
import { allowOnlyOneAtATime } from '../session/utils/Promise';
|
||||
import { toHex } from '../session/utils/String';
|
||||
import { toLogFormat } from '../types/attachments/Errors';
|
||||
import { processNewAttachment } from '../types/MessageAttachment';
|
||||
import { MIME } from '../types';
|
||||
import { autoScaleForIncomingAvatar } from '../util/attachmentsUtil';
|
||||
|
||||
export async function updateProfileOneAtATime(
|
||||
conversation: ConversationModel,
|
||||
|
@ -38,9 +42,9 @@ export async function updateProfileOneAtATime(
|
|||
async function createOrUpdateProfile(
|
||||
conversation: ConversationModel,
|
||||
profile: SignalService.DataMessage.ILokiProfile,
|
||||
profileKey?: Uint8Array | null // was any
|
||||
profileKey?: Uint8Array | null
|
||||
) {
|
||||
const { dcodeIO, textsecure, Signal } = window;
|
||||
const { dcodeIO, textsecure } = window;
|
||||
|
||||
// Retain old values unless changed:
|
||||
const newProfile = conversation.get('profile') || {};
|
||||
|
@ -72,9 +76,11 @@ async function createOrUpdateProfile(
|
|||
downloaded.data,
|
||||
profileKeyArrayBuffer
|
||||
);
|
||||
const upgraded = await Signal.Migrations.processNewAttachment({
|
||||
...downloaded,
|
||||
data: decryptedData,
|
||||
|
||||
const scaledData = await autoScaleForIncomingAvatar(decryptedData);
|
||||
const upgraded = await processNewAttachment({
|
||||
data: await scaledData.blob.arrayBuffer(),
|
||||
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case.
|
||||
});
|
||||
// Only update the convo if the download and decrypt is a success
|
||||
conversation.set('avatarPointer', profile.profilePicture);
|
||||
|
@ -361,7 +367,6 @@ export async function isMessageDuplicate({
|
|||
message,
|
||||
serverTimestamp,
|
||||
}: MessageId) {
|
||||
const { Errors } = window.Signal.Types;
|
||||
// serverTimestamp is only used for opengroupv2
|
||||
try {
|
||||
let result;
|
||||
|
@ -391,7 +396,7 @@ export async function isMessageDuplicate({
|
|||
const filteredResult = [result].filter((m: any) => m.attributes.body === message.body);
|
||||
return filteredResult.some(m => isDuplicate(m, message, source));
|
||||
} catch (error) {
|
||||
window?.log?.error('isMessageDuplicate error:', Errors.toLogFormat(error));
|
||||
window?.log?.error('isMessageDuplicate error:', toLogFormat(error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,10 +3,11 @@ import { toNumber } from 'lodash';
|
|||
import { getConversationController } from '../session/conversations';
|
||||
import { actions as conversationActions } from '../state/ducks/conversations';
|
||||
import { ConversationTypeEnum } from '../models/conversation';
|
||||
import { toLogFormat } from '../types/attachments/Errors';
|
||||
|
||||
export async function onError(ev: any) {
|
||||
const { error } = ev;
|
||||
window?.log?.error('background onError:', window.Signal.Errors.toLogFormat(error));
|
||||
window?.log?.error('background onError:', toLogFormat(error));
|
||||
|
||||
if (ev.proto) {
|
||||
const envelope = ev.proto;
|
||||
|
|
|
@ -55,20 +55,6 @@ async function copyFromQuotedMessage(msg: MessageModel, quote?: Quote): Promise<
|
|||
|
||||
firstAttachment.thumbnail = null;
|
||||
|
||||
try {
|
||||
if ((found.get('schemaVersion') || 0) < TypedMessage.VERSION_NEEDED_FOR_DISPLAY) {
|
||||
const upgradedMessage = await upgradeMessageSchema(found.attributes);
|
||||
found.set(upgradedMessage);
|
||||
await upgradedMessage.commit();
|
||||
}
|
||||
} catch (error) {
|
||||
window?.log?.error(
|
||||
'Problem upgrading message quoted message from database',
|
||||
Errors.toLogFormat(error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const queryAttachments = found.get('attachments') || [];
|
||||
|
||||
if (queryAttachments.length > 0) {
|
||||
|
@ -191,13 +177,10 @@ async function handleRegularMessage(
|
|||
ourNumber: string,
|
||||
messageHash: string
|
||||
) {
|
||||
const { upgradeMessageSchema } = window.Signal.Migrations;
|
||||
|
||||
const type = message.get('type');
|
||||
await copyFromQuotedMessage(message, initialMessage.quote);
|
||||
|
||||
// `upgradeMessageSchema` only seems to add `schemaVersion: 10` to the message
|
||||
const dataMessage = await upgradeMessageSchema(initialMessage);
|
||||
const dataMessage = initialMessage;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
|
|
|
@ -20,6 +20,8 @@ import { handleOpenGroupV2Message } from '../../../../receiver/receiver';
|
|||
import autoBind from 'auto-bind';
|
||||
import { sha256 } from '../../../crypto';
|
||||
import { DURATION } from '../../../constants';
|
||||
import { processNewAttachment } from '../../../../types/MessageAttachment';
|
||||
import { MIME } from '../../../../types';
|
||||
|
||||
const pollForEverythingInterval = DURATION.SECONDS * 10;
|
||||
const pollForRoomAvatarInterval = DURATION.DAYS * 1;
|
||||
|
@ -488,12 +490,11 @@ const handleBase64AvatarUpdate = async (
|
|||
if (newHash !== existingHash) {
|
||||
// write the file to the disk (automatically encrypted),
|
||||
// ArrayBuffer
|
||||
const { processNewAttachment } = window.Signal.Migrations;
|
||||
|
||||
const upgradedAttachment = await processNewAttachment({
|
||||
isRaw: true,
|
||||
data: await window.callWorker('fromBase64ToArrayBuffer', res.base64),
|
||||
url: `${serverUrl}/${res.roomId}`,
|
||||
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case. // url: `${serverUrl}/${res.roomId}`,
|
||||
});
|
||||
// update the hash on the conversationModel
|
||||
await convo.setLokiProfile({
|
||||
|
|
|
@ -1,85 +0,0 @@
|
|||
import { ApiV2 } from '.';
|
||||
import { getV2OpenGroupRoom } from '../../../../data/opengroups';
|
||||
import { ConversationModel } from '../../../../models/conversation';
|
||||
import { downloadAttachmentOpenGroupV2 } from '../../../../receiver/attachments';
|
||||
import { sha256 } from '../../../crypto';
|
||||
import { fromArrayBufferToBase64 } from '../../../utils/String';
|
||||
import { arrayBufferFromFile } from '../../../../types/Attachment';
|
||||
import { AttachmentUtil } from '../../../../util';
|
||||
|
||||
export async function updateOpenGroupV2(convo: ConversationModel, groupName: string, avatar: any) {
|
||||
if (avatar) {
|
||||
// I hate duplicating this...
|
||||
const readFile = async (attachment: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const fileReader = new FileReader();
|
||||
fileReader.onload = (e: any) => {
|
||||
const data = e.target.result;
|
||||
resolve({
|
||||
...attachment,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
});
|
||||
};
|
||||
fileReader.onerror = reject;
|
||||
fileReader.onabort = reject;
|
||||
fileReader.readAsArrayBuffer(attachment.file);
|
||||
});
|
||||
const avatarAttachment: any = await readFile({ file: avatar });
|
||||
|
||||
// We want a square
|
||||
const withBlob = await AttachmentUtil.autoScale(
|
||||
{
|
||||
contentType: avatar.type,
|
||||
file: new Blob([avatarAttachment.data], {
|
||||
type: avatar.contentType,
|
||||
}),
|
||||
},
|
||||
{
|
||||
maxSide: 640,
|
||||
maxSize: 1000 * 1024,
|
||||
}
|
||||
);
|
||||
const dataResized = await arrayBufferFromFile(withBlob.file);
|
||||
const roomInfos = await getV2OpenGroupRoom(convo.id);
|
||||
if (!roomInfos || !dataResized.byteLength) {
|
||||
return false;
|
||||
}
|
||||
const uploadedFileDetails = await ApiV2.uploadImageForRoomOpenGroupV2(
|
||||
new Uint8Array(dataResized),
|
||||
roomInfos
|
||||
);
|
||||
|
||||
if (!uploadedFileDetails || !uploadedFileDetails.fileUrl) {
|
||||
window?.log?.warn('File opengroupv2 upload failed');
|
||||
return;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(uploadedFileDetails.fileUrl);
|
||||
|
||||
const pathname = url.pathname;
|
||||
const downloaded = await downloadAttachmentOpenGroupV2(pathname, roomInfos);
|
||||
if (!(downloaded instanceof Uint8Array)) {
|
||||
const typeFound = typeof downloaded;
|
||||
throw new Error(`Expected a plain Uint8Array but got ${typeFound}`);
|
||||
}
|
||||
|
||||
const upgraded = await window.Signal.Migrations.processNewAttachment({
|
||||
data: downloaded.buffer,
|
||||
isRaw: true,
|
||||
url: pathname,
|
||||
});
|
||||
const newHash = sha256(fromArrayBufferToBase64(downloaded.buffer));
|
||||
await convo.setLokiProfile({
|
||||
displayName: groupName || convo.get('name') || 'Unknown',
|
||||
avatar: upgraded.path,
|
||||
avatarHash: newHash,
|
||||
});
|
||||
} catch (e) {
|
||||
window?.log?.error(`Could not decrypt profile image: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
|
@ -39,9 +39,10 @@ export const CONVERSATION = {
|
|||
// Maximum voice message duraton of 5 minutes
|
||||
// which equates to 1.97 MB
|
||||
MAX_VOICE_MESSAGE_DURATION: 300,
|
||||
// Max attachment size: 6 MB
|
||||
MAX_ATTACHMENT_FILESIZE_BYTES: 6 * 1000 * 1000,
|
||||
};
|
||||
// Max attachment size: 6 MB
|
||||
|
||||
export const MAX_ATTACHMENT_FILESIZE_BYTES = 6 * 1000 * 1000; // 6MB
|
||||
|
||||
export const VALIDATION = {
|
||||
MAX_GROUP_NAME_LENGTH: 64,
|
||||
|
|
|
@ -10,6 +10,8 @@ import toArrayBuffer from 'to-arraybuffer';
|
|||
import * as fse from 'fs-extra';
|
||||
import { decryptAttachmentBuffer } from '../../types/Attachment';
|
||||
import { DURATION } from '../constants';
|
||||
import { makeObjectUrl, urlToBlob } from '../../types/attachments/VisualAttachment';
|
||||
import { getAttachmentPath } from '../../types/MessageAttachment';
|
||||
|
||||
const urlToDecryptedBlobMap = new Map<
|
||||
string,
|
||||
|
@ -56,10 +58,7 @@ export const getDecryptedMediaUrl = async (
|
|||
}
|
||||
if (url.startsWith('blob:')) {
|
||||
return url;
|
||||
} else if (
|
||||
window.Signal.Migrations.attachmentsPath &&
|
||||
url.startsWith(window.Signal.Migrations.attachmentsPath)
|
||||
) {
|
||||
} else if (getAttachmentPath() && url.startsWith(getAttachmentPath())) {
|
||||
// this is a file encoded by session on our current attachments path.
|
||||
// we consider the file is encrypted.
|
||||
// if it's not, the hook caller has to fallback to setting the img src as an url to the file instead and load it
|
||||
|
@ -92,7 +91,6 @@ export const getDecryptedMediaUrl = async (
|
|||
);
|
||||
if (decryptedContent?.length) {
|
||||
const arrayBuffer = decryptedContent.buffer;
|
||||
const { makeObjectUrl } = window.Signal.Types.VisualAttachment;
|
||||
const obj = makeObjectUrl(arrayBuffer, contentType);
|
||||
|
||||
if (!urlToDecryptedBlobMap.has(url)) {
|
||||
|
@ -140,10 +138,7 @@ export const getAlreadyDecryptedMediaUrl = (url: string): string | null => {
|
|||
}
|
||||
if (url.startsWith('blob:')) {
|
||||
return url;
|
||||
} else if (
|
||||
window.Signal.Migrations.attachmentsPath &&
|
||||
url.startsWith(window.Signal.Migrations.attachmentsPath)
|
||||
) {
|
||||
} else if (getAttachmentPath() && url.startsWith(getAttachmentPath())) {
|
||||
if (urlToDecryptedBlobMap.has(url)) {
|
||||
const existingObjUrl = urlToDecryptedBlobMap.get(url)?.decrypted as string;
|
||||
return existingObjUrl;
|
||||
|
@ -151,3 +146,8 @@ export const getAlreadyDecryptedMediaUrl = (url: string): string | null => {
|
|||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getDecryptedBlob = async (url: string, contentType: string): Promise<Blob> => {
|
||||
const decryptedUrl = await getDecryptedMediaUrl(url, contentType, false);
|
||||
return urlToBlob(decryptedUrl);
|
||||
};
|
||||
|
|
|
@ -5,7 +5,7 @@ import _ from 'lodash';
|
|||
import { fromHexToArray, toHex } from '../utils/String';
|
||||
import { BlockedNumberController } from '../../util/blockedNumberController';
|
||||
import { getConversationController } from '../conversations';
|
||||
import { getLatestClosedGroupEncryptionKeyPair } from '../../../ts/data/data';
|
||||
import { getLatestClosedGroupEncryptionKeyPair } from '../../data/data';
|
||||
import uuid from 'uuid';
|
||||
import { SignalService } from '../../protobuf';
|
||||
import { generateCurve25519KeyPairWithoutPrefix } from '../crypto';
|
||||
|
@ -27,7 +27,6 @@ import { ClosedGroupEncryptionPairMessage } from '../messages/outgoing/controlMe
|
|||
import { ClosedGroupNameChangeMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNameChangeMessage';
|
||||
import { ClosedGroupNewMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupNewMessage';
|
||||
import { ClosedGroupRemovedMembersMessage } from '../messages/outgoing/controlMessage/group/ClosedGroupRemovedMembersMessage';
|
||||
import { updateOpenGroupV2 } from '../apis/open_group_api/opengroupV2/OpenGroupUpdate';
|
||||
import { getSwarmPollingInstance } from '../apis/snode_api';
|
||||
import { getLatestTimestampOffset } from '../apis/snode_api/SNodeAPI';
|
||||
|
||||
|
@ -66,27 +65,16 @@ export interface MemberChanges {
|
|||
* @param avatar the new avatar (or just pass the old one if nothing changed)
|
||||
* @returns nothing
|
||||
*/
|
||||
export async function initiateGroupUpdate(
|
||||
export async function initiateClosedGroupUpdate(
|
||||
groupId: string,
|
||||
groupName: string,
|
||||
members: Array<string>,
|
||||
avatar: any
|
||||
members: Array<string>
|
||||
) {
|
||||
const convo = await getConversationController().getOrCreateAndWait(
|
||||
groupId,
|
||||
ConversationTypeEnum.GROUP
|
||||
);
|
||||
|
||||
if (convo.isPublic()) {
|
||||
if (!convo.isOpenGroupV2()) {
|
||||
throw new Error('Only opengroupv2 are supported');
|
||||
} else {
|
||||
await updateOpenGroupV2(convo, groupName, avatar);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!convo.isMediumGroup()) {
|
||||
throw new Error('Legacy group are not supported anymore.');
|
||||
}
|
||||
|
@ -101,7 +89,7 @@ export async function initiateGroupUpdate(
|
|||
zombies: convo.get('zombies')?.filter(z => members.includes(z)),
|
||||
activeAt: Date.now(),
|
||||
expireTimer: convo.get('expireTimer'),
|
||||
avatar,
|
||||
avatar: null,
|
||||
};
|
||||
|
||||
const diff = buildGroupDiff(convo, groupDetails);
|
||||
|
@ -254,20 +242,6 @@ export async function updateOrCreateClosedGroup(details: GroupInfo) {
|
|||
|
||||
conversation.set(updates);
|
||||
|
||||
// Update the conversation avatar only if new avatar exists and hash differs
|
||||
const { avatar } = details;
|
||||
if (avatar && avatar.data) {
|
||||
const newAttributes = await window.Signal.Types.Conversation.maybeUpdateAvatar(
|
||||
conversation.attributes,
|
||||
avatar.data,
|
||||
{
|
||||
writeNewAttachmentData: window.Signal.writeNewAttachmentData,
|
||||
deleteAttachmentData: window.Signal.deleteAttachmentData,
|
||||
}
|
||||
);
|
||||
conversation.set(newAttributes);
|
||||
}
|
||||
|
||||
const isBlocked = details.blocked || false;
|
||||
if (conversation.isClosedGroup() || conversation.isMediumGroup()) {
|
||||
await BlockedNumberController.setGroupBlocked(conversation.id as string, isBlocked);
|
|
@ -0,0 +1,74 @@
|
|||
import { getV2OpenGroupRoom } from '../../data/opengroups';
|
||||
import { downloadDataFromOpenGroupV2 } from '../../receiver/attachments';
|
||||
import { MIME } from '../../types';
|
||||
import { urlToBlob } from '../../types/attachments/VisualAttachment';
|
||||
import { processNewAttachment } from '../../types/MessageAttachment';
|
||||
import { ApiV2 } from '../apis/open_group_api/opengroupV2';
|
||||
import { getConversationController } from '../conversations';
|
||||
import { sha256 } from '../crypto';
|
||||
import { fromArrayBufferToBase64 } from '../utils/String';
|
||||
|
||||
export type OpenGroupUpdateAvatar = { objectUrl: string | null };
|
||||
|
||||
/**
|
||||
* This function is only called when the local user makes a change to an open group.
|
||||
* So this function is not called on group updates from the network, even from another of our devices.
|
||||
*
|
||||
*/
|
||||
export async function initiateOpenGroupUpdate(
|
||||
groupId: string,
|
||||
groupName: string,
|
||||
avatar: OpenGroupUpdateAvatar
|
||||
) {
|
||||
const convo = getConversationController().get(groupId);
|
||||
|
||||
if (!convo || !convo.isPublic() || !convo.isOpenGroupV2()) {
|
||||
throw new Error('Only opengroupv2 are supported');
|
||||
}
|
||||
if (avatar && avatar.objectUrl) {
|
||||
const blobAvatarAlreadyScaled = await urlToBlob(avatar.objectUrl);
|
||||
|
||||
const dataResized = await blobAvatarAlreadyScaled.arrayBuffer();
|
||||
const roomInfos = await getV2OpenGroupRoom(convo.id);
|
||||
if (!roomInfos || !dataResized.byteLength) {
|
||||
return false;
|
||||
}
|
||||
const uploadedFileDetails = await ApiV2.uploadImageForRoomOpenGroupV2(
|
||||
new Uint8Array(dataResized),
|
||||
roomInfos
|
||||
);
|
||||
|
||||
if (!uploadedFileDetails || !uploadedFileDetails.fileUrl) {
|
||||
window?.log?.warn('File opengroupv2 upload failed');
|
||||
return false;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(uploadedFileDetails.fileUrl);
|
||||
|
||||
const pathname = url.pathname;
|
||||
const downloaded = await downloadDataFromOpenGroupV2(pathname, roomInfos);
|
||||
if (!(downloaded instanceof Uint8Array)) {
|
||||
const typeFound = typeof downloaded;
|
||||
throw new Error(`Expected a plain Uint8Array but got ${typeFound}`);
|
||||
}
|
||||
|
||||
const upgraded = await processNewAttachment({
|
||||
data: downloaded.buffer,
|
||||
isRaw: true,
|
||||
contentType: MIME.IMAGE_UNKNOWN, // contentType is mostly used to generate previews and screenshot. We do not care for those in this case.
|
||||
// url: pathname,
|
||||
});
|
||||
const newHash = sha256(fromArrayBufferToBase64(downloaded.buffer));
|
||||
await convo.setLokiProfile({
|
||||
displayName: groupName || convo.get('name') || 'Unknown',
|
||||
avatar: upgraded.path,
|
||||
avatarHash: newHash,
|
||||
});
|
||||
} catch (e) {
|
||||
window?.log?.error(`Could not decrypt profile image: ${e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
|
@ -4,7 +4,7 @@ import * as Types from './types';
|
|||
import * as Utils from './utils';
|
||||
import * as Sending from './sending';
|
||||
import * as Constants from './constants';
|
||||
import * as ClosedGroup from './group';
|
||||
import * as ClosedGroup from './group/closed-group';
|
||||
|
||||
const getMessageQueue = Sending.getMessageQueue;
|
||||
|
||||
|
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '../../../ts/data/data';
|
||||
import { MessageModel } from '../../models/message';
|
||||
import { downloadAttachment, downloadAttachmentOpenGroupV2 } from '../../receiver/attachments';
|
||||
import { processNewAttachment } from '../../types/MessageAttachment';
|
||||
|
||||
// this cause issues if we increment that value to > 1.
|
||||
const MAX_ATTACHMENT_JOB_PARALLELISM = 3;
|
||||
|
@ -31,6 +32,8 @@ let timeout: any;
|
|||
let logger: any;
|
||||
const _activeAttachmentDownloadJobs: any = {};
|
||||
|
||||
// FIXME audric, type those `any` field
|
||||
|
||||
export async function start(options: any = {}) {
|
||||
({ logger } = options);
|
||||
if (!logger) {
|
||||
|
@ -201,7 +204,13 @@ async function _runJob(job: any) {
|
|||
throw error;
|
||||
}
|
||||
|
||||
const upgradedAttachment = await window.Signal.Migrations.processNewAttachment(downloaded);
|
||||
if (!attachment.contentType) {
|
||||
window.log.warn('incoming attachment has no contentType');
|
||||
}
|
||||
const upgradedAttachment = await processNewAttachment({
|
||||
...downloaded,
|
||||
contentType: attachment.contentType,
|
||||
});
|
||||
found = await getMessageById(messageId);
|
||||
|
||||
await _addAttachmentToMessage(found, upgradedAttachment, { type, index });
|
||||
|
|
|
@ -1,197 +0,0 @@
|
|||
// import fse from 'fs-extra';
|
||||
// import path from 'path';
|
||||
// import tmp from 'tmp';
|
||||
// import { assert } from 'chai';
|
||||
|
||||
// import * as Attachments from '../../../../attachments/attachments';
|
||||
// import { stringToArrayBuffer } from '../../../../session/utils/String';
|
||||
// import { decryptAttachmentBuffer, encryptAttachmentBuffer } from '../../../../types/Attachment';
|
||||
// import { TestUtils } from '../../../test-utils';
|
||||
// import sinon from 'sinon';
|
||||
|
||||
const PREFIX_LENGTH = 2;
|
||||
const NUM_SEPARATORS = 1;
|
||||
const NAME_LENGTH = 64;
|
||||
const PATH_LENGTH = PREFIX_LENGTH + NUM_SEPARATORS + NAME_LENGTH;
|
||||
|
||||
// tslint:disable-next-line: max-func-body-length
|
||||
describe('Attachments', () => {
|
||||
// describe('createWriterForNew', () => {
|
||||
// let tempRootDirectory: any = null;
|
||||
// let sandbox: sinon.SinonSandbox;
|
||||
// beforeEach(() => {
|
||||
// tempRootDirectory = tmp.dirSync().name;
|
||||
// TestUtils.stubWindow('textsecure', {
|
||||
// storage: {
|
||||
// get: () => '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
// },
|
||||
// });
|
||||
// sandbox = sinon.createSandbox();
|
||||
// sandbox.stub(window, 'callWorker').resolves([]);
|
||||
// });
|
||||
// afterEach(async () => {
|
||||
// await fse.remove(tempRootDirectory);
|
||||
// sandbox.restore();
|
||||
// TestUtils.restoreStubs();
|
||||
// });
|
||||
// it('should write file to disk and return path', async () => {
|
||||
// const input = stringToArrayBuffer('test string');
|
||||
// const tempDirectory = path.join(tempRootDirectory, 'Attachments_createWriterForNew');
|
||||
// const outputPath = await Attachments.createWriterForNew(tempDirectory)(input);
|
||||
// const output = await fse.readFile(path.join(tempDirectory, outputPath));
|
||||
// assert.lengthOf(outputPath, PATH_LENGTH);
|
||||
// const outputDecrypted = Buffer.from(await decryptAttachmentBuffer(output.buffer));
|
||||
// const inputBuffer = Buffer.from(input);
|
||||
// assert.deepEqual(inputBuffer, outputDecrypted);
|
||||
// });
|
||||
// });
|
||||
// describe('createWriterForExisting', () => {
|
||||
// let tempRootDirectory: any = null;
|
||||
// before(() => {
|
||||
// tempRootDirectory = tmp.dirSync().name;
|
||||
// TestUtils.stubWindow('textsecure', {
|
||||
// storage: {
|
||||
// get: () => '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// after(async () => {
|
||||
// await fse.remove(tempRootDirectory);
|
||||
// TestUtils.restoreStubs();
|
||||
// });
|
||||
// it('should write file to disk on given path and return path', async () => {
|
||||
// const input = stringToArrayBuffer('test string');
|
||||
// const tempDirectory = path.join(tempRootDirectory, 'Attachments_createWriterForExisting');
|
||||
// const relativePath = Attachments.getRelativePath(Attachments.createName());
|
||||
// const attachment = {
|
||||
// path: relativePath,
|
||||
// data: input,
|
||||
// };
|
||||
// const outputPath = await Attachments.createWriterForExisting(tempDirectory)(attachment);
|
||||
// const output = await fse.readFile(path.join(tempDirectory, outputPath));
|
||||
// assert.equal(outputPath, relativePath);
|
||||
// const outputDecrypted = Buffer.from(await decryptAttachmentBuffer(output.buffer));
|
||||
// const inputBuffer = Buffer.from(input);
|
||||
// assert.deepEqual(inputBuffer, outputDecrypted);
|
||||
// });
|
||||
// it('throws if relative path goes higher than root', async () => {
|
||||
// const input = stringToArrayBuffer('test string');
|
||||
// const tempDirectory = path.join(tempRootDirectory, 'Attachments_createWriterForExisting');
|
||||
// const relativePath = '../../parent';
|
||||
// const attachment = {
|
||||
// path: relativePath,
|
||||
// data: input,
|
||||
// };
|
||||
// try {
|
||||
// await Attachments.createWriterForExisting(tempDirectory)(attachment);
|
||||
// } catch (error) {
|
||||
// assert.strictEqual(error.message, 'Invalid relative path');
|
||||
// return;
|
||||
// }
|
||||
// throw new Error('Expected an error');
|
||||
// });
|
||||
// });
|
||||
// describe('createReader', () => {
|
||||
// let tempRootDirectory: any = null;
|
||||
// before(() => {
|
||||
// tempRootDirectory = tmp.dirSync().name;
|
||||
// TestUtils.stubWindow('textsecure', {
|
||||
// storage: {
|
||||
// get: () => '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// after(async () => {
|
||||
// await fse.remove(tempRootDirectory);
|
||||
// TestUtils.restoreStubs();
|
||||
// });
|
||||
// it('should read file from disk', async () => {
|
||||
// const tempDirectory = path.join(tempRootDirectory, 'Attachments_createReader');
|
||||
// const relativePath = Attachments.getRelativePath(Attachments.createName());
|
||||
// const fullPath = path.join(tempDirectory, relativePath);
|
||||
// const input = stringToArrayBuffer('test string');
|
||||
// const encryptedInput = await encryptAttachmentBuffer(input);
|
||||
// const inputBuffer = Buffer.from(encryptedInput.encryptedBufferWithHeader);
|
||||
// await fse.ensureFile(fullPath);
|
||||
// await fse.writeFile(fullPath, inputBuffer);
|
||||
// const outputDecrypted = await Attachments.createReader(tempDirectory)(relativePath);
|
||||
// assert.deepEqual(new Uint8Array(input), new Uint8Array(outputDecrypted));
|
||||
// });
|
||||
// it('throws if relative path goes higher than root', async () => {
|
||||
// const tempDirectory = path.join(tempRootDirectory, 'Attachments_createReader');
|
||||
// const relativePath = '../../parent';
|
||||
// try {
|
||||
// await Attachments.createReader(tempDirectory)(relativePath);
|
||||
// } catch (error) {
|
||||
// assert.strictEqual(error.message, 'Invalid relative path');
|
||||
// return;
|
||||
// }
|
||||
// throw new Error('Expected an error');
|
||||
// });
|
||||
// });
|
||||
// describe('createDeleter', () => {
|
||||
// let tempRootDirectory: any = null;
|
||||
// before(() => {
|
||||
// tempRootDirectory = tmp.dirSync().name;
|
||||
// });
|
||||
// after(async () => {
|
||||
// await fse.remove(tempRootDirectory);
|
||||
// });
|
||||
// it('should delete file from disk', async () => {
|
||||
// const tempDirectory = path.join(tempRootDirectory, 'Attachments_createDeleter');
|
||||
// const relativePath = Attachments.getRelativePath(Attachments.createName());
|
||||
// const fullPath = path.join(tempDirectory, relativePath);
|
||||
// const input = stringToArrayBuffer('test string');
|
||||
// const inputBuffer = Buffer.from(input);
|
||||
// await fse.ensureFile(fullPath);
|
||||
// await fse.writeFile(fullPath, inputBuffer);
|
||||
// await Attachments.createDeleter(tempDirectory)(relativePath);
|
||||
// const existsFile = fse.existsSync(fullPath);
|
||||
// assert.isFalse(existsFile);
|
||||
// });
|
||||
// it('throws if relative path goes higher than root', async () => {
|
||||
// const tempDirectory = path.join(tempRootDirectory, 'Attachments_createDeleter');
|
||||
// const relativePath = '../../parent';
|
||||
// try {
|
||||
// await Attachments.createDeleter(tempDirectory)(relativePath);
|
||||
// } catch (error) {
|
||||
// assert.strictEqual(error.message, 'Invalid relative path');
|
||||
// return;
|
||||
// }
|
||||
// throw new Error('Expected an error');
|
||||
// });
|
||||
// });
|
||||
// describe('createName', () => {
|
||||
// it('should return random file name with correct length', () => {
|
||||
// assert.lengthOf(Attachments.createName(), NAME_LENGTH);
|
||||
// });
|
||||
// });
|
||||
// describe('getRelativePath', () => {
|
||||
// it('should return correct path', () => {
|
||||
// const name = '608ce3bc536edbf7637a6aeb6040bdfec49349140c0dd43e97c7ce263b15ff7e';
|
||||
// assert.lengthOf(Attachments.getRelativePath(name), PATH_LENGTH);
|
||||
// });
|
||||
// });
|
||||
// describe('createAbsolutePathGetter', () => {
|
||||
// const isWindows = process.platform === 'win32';
|
||||
// it('combines root and relative path', () => {
|
||||
// const root = isWindows ? 'C:\\temp' : '/tmp';
|
||||
// const relative = 'ab/abcdef';
|
||||
// const pathGetter = Attachments.createAbsolutePathGetter(root);
|
||||
// const absolutePath = pathGetter(relative);
|
||||
// assert.strictEqual(absolutePath, isWindows ? 'C:\\temp\\ab\\abcdef' : '/tmp/ab/abcdef');
|
||||
// });
|
||||
// it('throws if relative path goes higher than root', () => {
|
||||
// const root = isWindows ? 'C:\\temp' : 'tmp';
|
||||
// const relative = '../../ab/abcdef';
|
||||
// const pathGetter = Attachments.createAbsolutePathGetter(root);
|
||||
// try {
|
||||
// pathGetter(relative);
|
||||
// } catch (error) {
|
||||
// assert.strictEqual(error.message, 'Invalid relative path');
|
||||
// return;
|
||||
// }
|
||||
// throw new Error('Expected an error');
|
||||
// });
|
||||
// });
|
||||
});
|
|
@ -185,7 +185,7 @@ export async function arrayBufferFromFile(file: any): Promise<ArrayBuffer> {
|
|||
});
|
||||
}
|
||||
|
||||
export function getImageDimensions(attachment: AttachmentType): DimensionsType {
|
||||
export function getImageDimensionsInAttachment(attachment: AttachmentType): DimensionsType {
|
||||
const { height, width } = attachment;
|
||||
if (!height || !width) {
|
||||
return {
|
||||
|
@ -230,7 +230,7 @@ export function getGridDimensions(attachments?: Array<AttachmentType>): null | D
|
|||
}
|
||||
|
||||
if (attachments.length === 1) {
|
||||
return getImageDimensions(attachments[0]);
|
||||
return getImageDimensionsInAttachment(attachments[0]);
|
||||
}
|
||||
|
||||
if (attachments.length === 2) {
|
||||
|
|
|
@ -12,6 +12,8 @@ export const IMAGE_BMP = 'image/bmp' as MIMEType;
|
|||
export const IMAGE_ICO = 'image/x-icon' as MIMEType;
|
||||
export const IMAGE_WEBP = 'image/webp' as MIMEType;
|
||||
export const IMAGE_PNG = 'image/png' as MIMEType;
|
||||
export const IMAGE_TIFF = 'image/tiff' as MIMEType;
|
||||
export const IMAGE_UNKNOWN = 'image/unknown' as MIMEType;
|
||||
export const VIDEO_MP4 = 'video/mp4' as MIMEType;
|
||||
export const VIDEO_QUICKTIME = 'video/quicktime' as MIMEType;
|
||||
export const ODT = 'application/vnd.oasis.opendocument.spreadsheet' as MIMEType;
|
||||
|
|
|
@ -0,0 +1,251 @@
|
|||
import { isArrayBuffer, isUndefined, omit } from 'lodash';
|
||||
import {
|
||||
createAbsolutePathGetter,
|
||||
createDeleter,
|
||||
createReader,
|
||||
createWriterForNew,
|
||||
getPath,
|
||||
} from '../attachments/attachments';
|
||||
import {
|
||||
autoOrientJPEG,
|
||||
captureDimensionsAndScreenshot,
|
||||
deleteData,
|
||||
loadData,
|
||||
} from './attachments/migrations';
|
||||
|
||||
// tslint:disable: prefer-object-spread
|
||||
|
||||
// FIXME audric
|
||||
// upgrade: exports._mapAttachments(autoOrientJPEG),
|
||||
// upgrade: exports._mapAttachments(replaceUnicodeOrderOverrides),
|
||||
// upgrade: _mapAttachments(migrateDataToFileSystem),
|
||||
// upgrade: ._mapQuotedAttachments(migrateDataToFileSystem),
|
||||
// upgrade: initializeAttachmentMetadata,
|
||||
// upgrade: initializeAttachmentMetadata,
|
||||
// upgrade: _mapAttachments(captureDimensionsAndScreenshot),
|
||||
// upgrade: _mapAttachments(replaceUnicodeV2),
|
||||
// upgrade: _mapPreviewAttachments(migrateDataToFileSystem),
|
||||
|
||||
export const deleteExternalMessageFiles = async (message: {
|
||||
attachments: any;
|
||||
quote: any;
|
||||
contact: any;
|
||||
preview: any;
|
||||
}) => {
|
||||
const { attachments, quote, contact, preview } = message;
|
||||
|
||||
if (attachments && attachments.length) {
|
||||
await Promise.all(attachments.map(deleteData));
|
||||
}
|
||||
|
||||
if (quote && quote.attachments && quote.attachments.length) {
|
||||
await Promise.all(
|
||||
quote.attachments.map(async (attachment: { thumbnail: any }) => {
|
||||
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: { avatar: any }) => {
|
||||
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: { image: any }) => {
|
||||
const { image } = item;
|
||||
|
||||
if (image && image.path) {
|
||||
await deleteOnDisk(image.path);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let attachmentsPath: string | undefined;
|
||||
|
||||
let internalReadAttachmentData: ((relativePath: string) => Promise<ArrayBufferLike>) | undefined;
|
||||
let internalGetAbsoluteAttachmentPath: ((relativePath: string) => string) | undefined;
|
||||
let internalDeleteOnDisk: ((relativePath: string) => Promise<void>) | undefined;
|
||||
let internalWriteNewAttachmentData: ((arrayBuffer: ArrayBuffer) => Promise<string>) | undefined;
|
||||
|
||||
// userDataPath must be app.getPath('userData');
|
||||
export function initializeAttachmentLogic(userDataPath: string) {
|
||||
if (attachmentsPath) {
|
||||
throw new Error('attachmentsPath already initialized');
|
||||
}
|
||||
|
||||
if (!userDataPath || userDataPath.length <= 10) {
|
||||
throw new Error('userDataPath cannot have length <= 10');
|
||||
}
|
||||
attachmentsPath = getPath(userDataPath);
|
||||
internalReadAttachmentData = createReader(attachmentsPath);
|
||||
internalGetAbsoluteAttachmentPath = createAbsolutePathGetter(attachmentsPath);
|
||||
internalDeleteOnDisk = createDeleter(attachmentsPath);
|
||||
internalWriteNewAttachmentData = createWriterForNew(attachmentsPath);
|
||||
}
|
||||
|
||||
export const getAttachmentPath = () => {
|
||||
if (!attachmentsPath) {
|
||||
throw new Error('attachmentsPath not init');
|
||||
}
|
||||
return attachmentsPath;
|
||||
};
|
||||
|
||||
export const loadAttachmentData = loadData();
|
||||
|
||||
export const loadPreviewData = async (preview: any) => {
|
||||
if (!preview || !preview.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Promise.all(
|
||||
preview.map(async (item: any) => {
|
||||
if (!item.image) {
|
||||
return item;
|
||||
}
|
||||
|
||||
return {
|
||||
...item,
|
||||
image: await loadAttachmentData(item.image),
|
||||
};
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const loadQuoteData = async (quote: any) => {
|
||||
if (!quote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...quote,
|
||||
attachments: await Promise.all(
|
||||
(quote.attachments || []).map(async (attachment: any) => {
|
||||
const { thumbnail } = attachment;
|
||||
|
||||
if (!thumbnail || !thumbnail.path) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
return {
|
||||
...attachment,
|
||||
thumbnail: await loadAttachmentData(thumbnail),
|
||||
};
|
||||
})
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const processNewAttachment = async (attachment: {
|
||||
contentType: string;
|
||||
data: ArrayBuffer;
|
||||
digest?: string;
|
||||
path?: string;
|
||||
isRaw?: boolean;
|
||||
}) => {
|
||||
const rotatedData = await autoOrientJPEG(attachment);
|
||||
const rotatedAttachment = {
|
||||
...attachment,
|
||||
contentType: rotatedData.contentType,
|
||||
data: rotatedData.data,
|
||||
digest: attachment.digest as string | undefined,
|
||||
};
|
||||
if (rotatedData.shouldDeleteDigest) {
|
||||
delete rotatedAttachment.digest;
|
||||
}
|
||||
|
||||
const onDiskAttachmentPath = await migrateDataToFileSystem(rotatedAttachment.data);
|
||||
|
||||
const attachmentWithoutData = omit({ ...attachment, path: onDiskAttachmentPath }, 'data');
|
||||
const finalAttachment = await captureDimensionsAndScreenshot(attachmentWithoutData);
|
||||
|
||||
return finalAttachment;
|
||||
};
|
||||
|
||||
export const readAttachmentData = async (relativePath: string): Promise<ArrayBufferLike> => {
|
||||
if (!internalReadAttachmentData) {
|
||||
throw new Error('attachment logic not initialized');
|
||||
}
|
||||
return internalReadAttachmentData(relativePath);
|
||||
};
|
||||
|
||||
export const getAbsoluteAttachmentPath = (relativePath?: string): string => {
|
||||
if (!internalGetAbsoluteAttachmentPath) {
|
||||
throw new Error('attachment logic not initialized');
|
||||
}
|
||||
return internalGetAbsoluteAttachmentPath(relativePath || '');
|
||||
};
|
||||
|
||||
export const deleteOnDisk = async (relativePath: string): Promise<void> => {
|
||||
if (!internalDeleteOnDisk) {
|
||||
throw new Error('attachment logic not initialized');
|
||||
}
|
||||
return internalDeleteOnDisk(relativePath);
|
||||
};
|
||||
|
||||
export const writeNewAttachmentData = async (arrayBuffer: ArrayBuffer): Promise<string> => {
|
||||
if (!internalWriteNewAttachmentData) {
|
||||
throw new Error('attachment logic not initialized');
|
||||
}
|
||||
return internalWriteNewAttachmentData(arrayBuffer);
|
||||
};
|
||||
|
||||
// type Context :: {
|
||||
// writeNewAttachmentData :: ArrayBuffer -> Promise (IO Path)
|
||||
// }
|
||||
//
|
||||
// migrateDataToFileSystem :: Attachment ->
|
||||
// Context ->
|
||||
// Promise Attachment
|
||||
export const migrateDataToFileSystem = async (data?: ArrayBuffer) => {
|
||||
const hasDataField = !isUndefined(data);
|
||||
|
||||
if (!hasDataField) {
|
||||
throw new Error('attachment has no data in migrateDataToFileSystem');
|
||||
}
|
||||
|
||||
const isValidData = isArrayBuffer(data);
|
||||
if (!isValidData) {
|
||||
throw new TypeError(`Expected ${data} to be an array buffer got: ${typeof data}`);
|
||||
}
|
||||
|
||||
const path = await writeNewAttachmentData(data);
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
export async function deleteExternalFilesOfConversation(conversation: {
|
||||
avatar: any;
|
||||
profileAvatar: any;
|
||||
}) {
|
||||
if (!conversation) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { avatar, profileAvatar } = conversation;
|
||||
|
||||
if (avatar && avatar.path) {
|
||||
await deleteOnDisk(avatar.path);
|
||||
}
|
||||
|
||||
if (profileAvatar && profileAvatar.path) {
|
||||
await deleteOnDisk(profileAvatar.path);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
// toLogFormat :: Error -> String
|
||||
exports.toLogFormat = error => {
|
||||
export const toLogFormat = (error: any) => {
|
||||
if (!error) {
|
||||
return error;
|
||||
}
|
|
@ -0,0 +1,149 @@
|
|||
/* eslint-disable more/no-then */
|
||||
/* global document, URL, Blob */
|
||||
|
||||
import { toLogFormat } from './Errors';
|
||||
|
||||
import {
|
||||
getDecryptedBlob,
|
||||
getDecryptedMediaUrl,
|
||||
} from '../../../ts/session/crypto/DecryptedAttachmentsManager';
|
||||
import { blobToArrayBuffer, dataURLToBlob } from 'blob-util';
|
||||
import { autoScaleForAvatar, autoScaleForThumbnail } from '../../util/attachmentsUtil';
|
||||
import { GoogleChrome } from '../../util';
|
||||
import { ToastUtils } from '../../session/utils';
|
||||
|
||||
export const THUMBNAIL_SIDE = 200;
|
||||
export const THUMBNAIL_CONTENT_TYPE = 'image/png';
|
||||
|
||||
export const urlToBlob = async (dataUrl: string) => {
|
||||
return (await fetch(dataUrl)).blob();
|
||||
};
|
||||
|
||||
export const getImageDimensions = async ({
|
||||
objectUrl,
|
||||
}: {
|
||||
objectUrl: string;
|
||||
}): Promise<{ height: number; width: number }> =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
const image = document.createElement('img');
|
||||
|
||||
image.addEventListener('load', () => {
|
||||
resolve({
|
||||
height: image.naturalHeight,
|
||||
width: image.naturalWidth,
|
||||
});
|
||||
});
|
||||
image.addEventListener('error', error => {
|
||||
window.log.error('getImageDimensions error', toLogFormat(error));
|
||||
reject(error);
|
||||
});
|
||||
// TODO image/jpeg is hard coded, but it does not look to cause any issues
|
||||
const decryptedUrl = await getDecryptedMediaUrl(objectUrl, 'image/jpg', false);
|
||||
image.src = decryptedUrl;
|
||||
});
|
||||
|
||||
export const makeImageThumbnailBuffer = async ({
|
||||
objectUrl,
|
||||
contentType,
|
||||
}: {
|
||||
objectUrl: string;
|
||||
contentType: string;
|
||||
}) => {
|
||||
if (!GoogleChrome.isImageTypeSupported(contentType)) {
|
||||
throw new Error(
|
||||
'makeImageThumbnailBuffer can only be called with what GoogleChrome image type supports'
|
||||
);
|
||||
}
|
||||
const decryptedBlob = await getDecryptedBlob(objectUrl, contentType);
|
||||
const scaled = await autoScaleForThumbnail({ contentType, blob: decryptedBlob });
|
||||
|
||||
return blobToArrayBuffer(scaled.blob);
|
||||
};
|
||||
|
||||
export const makeVideoScreenshot = async ({
|
||||
objectUrl,
|
||||
contentType = 'image/png',
|
||||
}: {
|
||||
objectUrl: string;
|
||||
contentType: string | undefined;
|
||||
}) =>
|
||||
new Promise<Blob>(async (resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
|
||||
function capture() {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctxCanvas = canvas.getContext('2d');
|
||||
if (!ctxCanvas) {
|
||||
throw new Error('Failed to get a 2d context for canvas of video in capture()');
|
||||
}
|
||||
ctxCanvas.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const blob = dataURLToBlob(canvas.toDataURL(contentType));
|
||||
|
||||
video.removeEventListener('canplay', capture);
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
resolve(blob);
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', capture);
|
||||
video.addEventListener('error', error => {
|
||||
window.log.error('makeVideoScreenshot error', toLogFormat(error));
|
||||
reject(error);
|
||||
});
|
||||
|
||||
const decryptedUrl = await getDecryptedMediaUrl(objectUrl, contentType, false);
|
||||
video.src = decryptedUrl;
|
||||
video.muted = true;
|
||||
// for some reason, this is to be started, otherwise the generated thumbnail will be empty
|
||||
await video.play();
|
||||
});
|
||||
|
||||
export const makeObjectUrl = (data: ArrayBufferLike, contentType: string) => {
|
||||
const blob = new Blob([data], {
|
||||
type: contentType,
|
||||
});
|
||||
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
export const revokeObjectUrl = (objectUrl: string) => {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
|
||||
/**
|
||||
* Shows the system file picker for images, scale the image down for avatar/opengroup measurements and return the blob objectURL on success
|
||||
*/
|
||||
export async function pickFileForAvatar(): Promise<string | null> {
|
||||
const [fileHandle] = await (window as any).showOpenFilePicker({
|
||||
types: [
|
||||
{
|
||||
description: 'Images',
|
||||
accept: {
|
||||
'image/*': ['.png', '.gif', '.jpeg', '.jpg'],
|
||||
},
|
||||
},
|
||||
],
|
||||
excludeAcceptAllOption: true,
|
||||
multiple: false,
|
||||
});
|
||||
const file = (await fileHandle.getFile()) as File;
|
||||
|
||||
try {
|
||||
const scaled = await autoScaleForAvatar({ blob: file, contentType: file.type });
|
||||
|
||||
const url = window.URL.createObjectURL(scaled.blob);
|
||||
|
||||
return url;
|
||||
} catch (e) {
|
||||
ToastUtils.pushToastError(
|
||||
'pickFileForAvatar',
|
||||
'An error happened while picking/resizing the image',
|
||||
e.message || ''
|
||||
);
|
||||
window.log.error(e);
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,351 @@
|
|||
import * as GoogleChrome from '../../../ts/util/GoogleChrome';
|
||||
import * as MIME from '../../../ts/types/MIME';
|
||||
import { toLogFormat } from './Errors';
|
||||
import { arrayBufferToBlob, blobToArrayBuffer, dataURLToBlob } from 'blob-util';
|
||||
|
||||
import loadImage from 'blueimp-load-image';
|
||||
import { isString } from 'lodash';
|
||||
import {
|
||||
getImageDimensions,
|
||||
makeImageThumbnailBuffer,
|
||||
makeObjectUrl,
|
||||
makeVideoScreenshot,
|
||||
revokeObjectUrl,
|
||||
THUMBNAIL_CONTENT_TYPE,
|
||||
THUMBNAIL_SIDE,
|
||||
} from './VisualAttachment';
|
||||
import {
|
||||
deleteOnDisk,
|
||||
getAbsoluteAttachmentPath,
|
||||
readAttachmentData,
|
||||
writeNewAttachmentData,
|
||||
} from '../MessageAttachment';
|
||||
|
||||
const DEFAULT_JPEG_QUALITY = 0.85;
|
||||
|
||||
// File | Blob | URLString -> LoadImageOptions -> Promise<DataURLString>
|
||||
//
|
||||
// Documentation for `options` (`LoadImageOptions`):
|
||||
// https://github.com/blueimp/JavaScript-Load-Image/tree/v2.18.0#options
|
||||
export const autoOrientImage = async (
|
||||
fileOrBlobOrURL: string | File | Blob,
|
||||
options = {}
|
||||
): Promise<string> => {
|
||||
const optionsWithDefaults = {
|
||||
type: 'image/jpeg',
|
||||
quality: DEFAULT_JPEG_QUALITY,
|
||||
...options,
|
||||
|
||||
canvas: true,
|
||||
orientation: true,
|
||||
maxHeight: 4096, // ATTACHMENT_DEFAULT_MAX_SIDE
|
||||
maxWidth: 4096,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
loadImage(
|
||||
fileOrBlobOrURL,
|
||||
canvasOrError => {
|
||||
if ((canvasOrError as any).type === 'error') {
|
||||
const error = new Error('autoOrientImage: Failed to process image');
|
||||
(error as any).cause = canvasOrError;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasOrError as HTMLCanvasElement;
|
||||
const dataURL = canvas.toDataURL(optionsWithDefaults.type, optionsWithDefaults.quality);
|
||||
|
||||
resolve(dataURL);
|
||||
},
|
||||
optionsWithDefaults
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 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 const isValid = (rawAttachment: any) => {
|
||||
// NOTE: We cannot use `_.isPlainObject` because `rawAttachment` is
|
||||
// deserialized by protobuf:
|
||||
if (!rawAttachment) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
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'
|
||||
);
|
||||
|
||||
// Upgrade steps
|
||||
// NOTE: This step strips all EXIF metadata from JPEG images as
|
||||
// part of re-encoding the image:
|
||||
export const autoOrientJPEG = async (attachment: {
|
||||
contentType: string;
|
||||
data: ArrayBuffer;
|
||||
}): Promise<{ contentType: string; data: ArrayBuffer; shouldDeleteDigest: boolean }> => {
|
||||
if (!attachment.contentType || !MIME.isJPEG(attachment.contentType)) {
|
||||
return { ...attachment, shouldDeleteDigest: false };
|
||||
}
|
||||
|
||||
// If we haven't downloaded the attachment yet, we won't have the data
|
||||
if (!attachment.data) {
|
||||
return { ...attachment, shouldDeleteDigest: false };
|
||||
}
|
||||
|
||||
const dataBlob = arrayBufferToBlob(attachment.data, attachment.contentType);
|
||||
const newDataBlob = 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
|
||||
// Also, `digest` is no longer valid for auto-oriented image data, so we discard it:
|
||||
|
||||
return {
|
||||
contentType: attachment.contentType,
|
||||
shouldDeleteDigest: true,
|
||||
data: newDataArrayBuffer,
|
||||
};
|
||||
};
|
||||
// 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
|
||||
export const _replaceUnicodeOrderOverridesSync = (attachment: any) => {
|
||||
if (!isString(attachment.fileName)) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
const normalizedFilename = attachment.fileName.replace(
|
||||
INVALID_CHARACTERS_PATTERN,
|
||||
UNICODE_REPLACEMENT_CHARACTER
|
||||
);
|
||||
const newAttachment = { ...attachment, fileName: normalizedFilename };
|
||||
|
||||
return newAttachment;
|
||||
};
|
||||
|
||||
// const replaceUnicodeOrderOverrides = async (attachment: any) =>
|
||||
// _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;
|
||||
|
||||
// const replaceUnicodeV2 = async (attachment: any) => {
|
||||
// if (!isString(attachment.fileName)) {
|
||||
// return attachment;
|
||||
// }
|
||||
|
||||
// const fileName = attachment.fileName.replace(V2_UNWANTED_UNICODE, UNICODE_REPLACEMENT_CHARACTER);
|
||||
|
||||
// return {
|
||||
// ...attachment,
|
||||
// fileName,
|
||||
// };
|
||||
// };
|
||||
|
||||
// const removeSchemaVersion = ({ attachment }: any) => {
|
||||
// if (!isValid(attachment)) {
|
||||
// window.log.error('Attachment.removeSchemaVersion: Invalid input attachment:', attachment);
|
||||
// return attachment;
|
||||
// }
|
||||
|
||||
// const attachmentWithoutSchemaVersion = { ...attachment };
|
||||
// delete attachmentWithoutSchemaVersion.schemaVersion;
|
||||
// return attachmentWithoutSchemaVersion;
|
||||
// };
|
||||
|
||||
// hasData :: Attachment -> Boolean
|
||||
export const hasData = (attachment: any) =>
|
||||
attachment.data instanceof ArrayBuffer || ArrayBuffer.isView(attachment.data);
|
||||
|
||||
// loadData :: (RelativePath -> IO (Promise ArrayBuffer))
|
||||
// Attachment ->
|
||||
// IO (Promise Attachment)
|
||||
export const loadData = () => {
|
||||
return async (attachment: any) => {
|
||||
if (!isValid(attachment)) {
|
||||
throw new TypeError("'attachment' is not valid");
|
||||
}
|
||||
|
||||
const isAlreadyLoaded = hasData(attachment);
|
||||
|
||||
if (isAlreadyLoaded) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
if (!isString(attachment.path)) {
|
||||
throw new TypeError("'attachment.path' is required");
|
||||
}
|
||||
|
||||
const data = await readAttachmentData(attachment.path);
|
||||
return { ...attachment, data };
|
||||
};
|
||||
};
|
||||
|
||||
// deleteData :: (RelativePath -> IO Unit)
|
||||
// Attachment ->
|
||||
// IO Unit
|
||||
export const deleteData = () => {
|
||||
return async (attachment: { path: string; thumbnail: any; screenshot: any }) => {
|
||||
if (!isValid(attachment)) {
|
||||
throw new TypeError('deleteData: attachment is not valid');
|
||||
}
|
||||
|
||||
const { path, thumbnail, screenshot } = attachment;
|
||||
if (isString(path)) {
|
||||
await deleteOnDisk(path);
|
||||
}
|
||||
|
||||
if (thumbnail && isString(thumbnail.path)) {
|
||||
await deleteOnDisk(thumbnail.path);
|
||||
}
|
||||
|
||||
if (screenshot && isString(screenshot.path)) {
|
||||
await deleteOnDisk(screenshot.path);
|
||||
}
|
||||
};
|
||||
};
|
||||
type CaptureDimensionType = { contentType: string; path: string };
|
||||
|
||||
export const captureDimensionsAndScreenshot = async (
|
||||
attachment: CaptureDimensionType
|
||||
): Promise<| CaptureDimensionType
|
||||
| (CaptureDimensionType & {
|
||||
width: number;
|
||||
height: number;
|
||||
thumbnail: {
|
||||
path: string;
|
||||
contentType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
})
|
||||
| (CaptureDimensionType & {
|
||||
width: number;
|
||||
height: number;
|
||||
thumbnail: {
|
||||
path: string;
|
||||
contentType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
screenshot: {
|
||||
path: string;
|
||||
contentType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
})> => {
|
||||
const { contentType } = attachment;
|
||||
|
||||
if (
|
||||
!contentType ||
|
||||
(!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 = getAbsoluteAttachmentPath(attachment.path);
|
||||
|
||||
if (GoogleChrome.isImageTypeSupported(contentType)) {
|
||||
try {
|
||||
const { width, height } = await getImageDimensions({
|
||||
objectUrl: absolutePath,
|
||||
});
|
||||
const thumbnailBuffer = await makeImageThumbnailBuffer({
|
||||
objectUrl: absolutePath,
|
||||
contentType,
|
||||
});
|
||||
|
||||
const thumbnailPath = await writeNewAttachmentData(thumbnailBuffer);
|
||||
return {
|
||||
...attachment,
|
||||
width,
|
||||
height,
|
||||
thumbnail: {
|
||||
path: thumbnailPath,
|
||||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||||
width: THUMBNAIL_SIDE,
|
||||
height: THUMBNAIL_SIDE,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
window.log.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,
|
||||
})
|
||||
);
|
||||
screenshotObjectUrl = makeObjectUrl(screenshotBuffer, THUMBNAIL_CONTENT_TYPE);
|
||||
const { width, height } = await getImageDimensions({
|
||||
objectUrl: screenshotObjectUrl,
|
||||
});
|
||||
const screenshotPath = await writeNewAttachmentData(screenshotBuffer);
|
||||
|
||||
const thumbnailBuffer = await makeImageThumbnailBuffer({
|
||||
objectUrl: screenshotObjectUrl,
|
||||
contentType: THUMBNAIL_CONTENT_TYPE,
|
||||
});
|
||||
|
||||
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_SIDE,
|
||||
height: THUMBNAIL_SIDE,
|
||||
},
|
||||
width,
|
||||
height,
|
||||
};
|
||||
} catch (error) {
|
||||
window.log.error(
|
||||
'captureDimensionsAndScreenshot: error processing video; skipping screenshot generation',
|
||||
toLogFormat(error)
|
||||
);
|
||||
return attachment;
|
||||
} finally {
|
||||
if (screenshotObjectUrl) {
|
||||
revokeObjectUrl(screenshotObjectUrl);
|
||||
}
|
||||
}
|
||||
};
|
|
@ -1,10 +1,17 @@
|
|||
import { SignalService } from '../protobuf';
|
||||
import { Constants } from '../session';
|
||||
import loadImage from 'blueimp-load-image';
|
||||
import loadImage, { CropOptions, LoadImageOptions } from 'blueimp-load-image';
|
||||
import { getDecryptedMediaUrl } from '../session/crypto/DecryptedAttachmentsManager';
|
||||
import { sendDataExtractionNotification } from '../session/messages/outgoing/controlMessage/DataExtractionNotificationMessage';
|
||||
import { AttachmentType, save } from '../types/Attachment';
|
||||
import { StagedAttachmentType } from '../components/conversation/composition/CompositionBox';
|
||||
import { getAbsoluteAttachmentPath, processNewAttachment } from '../types/MessageAttachment';
|
||||
import { arrayBufferToBlob, dataURLToBlob } from 'blob-util';
|
||||
import { IMAGE_GIF, IMAGE_JPEG, IMAGE_PNG, IMAGE_TIFF, IMAGE_UNKNOWN } from '../types/MIME';
|
||||
import { THUMBNAIL_SIDE } from '../types/attachments/VisualAttachment';
|
||||
|
||||
import imageType from 'image-type';
|
||||
import { MAX_ATTACHMENT_FILESIZE_BYTES } from '../session/constants';
|
||||
|
||||
export interface MaxScaleSize {
|
||||
maxSize?: number;
|
||||
maxHeight?: number;
|
||||
|
@ -14,109 +21,231 @@ export interface MaxScaleSize {
|
|||
|
||||
export const ATTACHMENT_DEFAULT_MAX_SIDE = 4096;
|
||||
|
||||
/**
|
||||
* Resize a jpg/gif/png file to our definition on an avatar before upload
|
||||
*/
|
||||
export async function autoScaleForAvatar<T extends { contentType: string; blob: Blob }>(
|
||||
attachment: T
|
||||
) {
|
||||
const maxMeasurements = {
|
||||
maxSide: 640,
|
||||
maxSize: 1000 * 1024,
|
||||
};
|
||||
|
||||
// we can only upload jpeg, gif, or png as avatar/opengroup
|
||||
|
||||
if (
|
||||
attachment.contentType !== IMAGE_PNG &&
|
||||
attachment.contentType !== IMAGE_GIF &&
|
||||
attachment.contentType !== IMAGE_JPEG
|
||||
) {
|
||||
// nothing to do
|
||||
throw new Error('Cannot autoScaleForAvatar another file than PNG,GIF or JPEG.');
|
||||
}
|
||||
|
||||
return autoScale(attachment, maxMeasurements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an avatar when we receive it, before saving it locally.
|
||||
*/
|
||||
export async function autoScaleForIncomingAvatar(incomingAvatar: ArrayBuffer) {
|
||||
const maxMeasurements = {
|
||||
maxSide: 640,
|
||||
maxSize: 1000 * 1024,
|
||||
};
|
||||
|
||||
// the avatar url send in a message does not contain anything related to the avatar MIME type, so
|
||||
// we use imageType to find the MIMEtype from the buffer itself
|
||||
|
||||
const contentType = imageType(new Uint8Array(incomingAvatar))?.mime || IMAGE_UNKNOWN;
|
||||
const blob = arrayBufferToBlob(incomingAvatar, contentType);
|
||||
// we do not know how to resize an incoming gif avatar, so just keep it full sized.
|
||||
if (contentType === IMAGE_GIF) {
|
||||
return {
|
||||
contentType,
|
||||
blob,
|
||||
};
|
||||
}
|
||||
return autoScale(
|
||||
{
|
||||
blob,
|
||||
contentType,
|
||||
},
|
||||
maxMeasurements
|
||||
);
|
||||
}
|
||||
|
||||
export async function autoScaleForThumbnail<T extends { contentType: string; blob: Blob }>(
|
||||
attachment: T
|
||||
) {
|
||||
const maxMeasurements = {
|
||||
maxSide: THUMBNAIL_SIDE,
|
||||
maxSize: 200 * 1000, // 200 ko
|
||||
};
|
||||
|
||||
return autoScale(attachment, maxMeasurements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale down an image to fit in the required dimension.
|
||||
* Note: This method won't crop if needed,
|
||||
* @param attachment The attachment to scale down
|
||||
* @param maxMeasurements any of those will be used if set
|
||||
*/
|
||||
export async function autoScale<T extends { contentType: string; file: any }>(
|
||||
// tslint:disable-next-line: cyclomatic-complexity
|
||||
export async function autoScale<T extends { contentType: string; blob: Blob }>(
|
||||
attachment: T,
|
||||
maxMeasurements?: MaxScaleSize
|
||||
): Promise<T> {
|
||||
const { contentType, file } = attachment;
|
||||
if (contentType.split('/')[0] !== 'image' || contentType === 'image/tiff') {
|
||||
): Promise<{
|
||||
contentType: string;
|
||||
blob: Blob;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}> {
|
||||
const { contentType, blob } = attachment;
|
||||
if (contentType.split('/')[0] !== 'image' || contentType === IMAGE_TIFF) {
|
||||
// nothing to do
|
||||
return Promise.resolve(attachment);
|
||||
return attachment;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = document.createElement('img');
|
||||
img.onerror = reject;
|
||||
// tslint:disable-next-line: cyclomatic-complexity
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
if (maxMeasurements?.maxSide && (maxMeasurements?.maxHeight || maxMeasurements?.maxWidth)) {
|
||||
throw new Error('Cannot have maxSide and another dimension set together');
|
||||
}
|
||||
|
||||
if (maxMeasurements?.maxSide && (maxMeasurements?.maxHeight || maxMeasurements?.maxWidth)) {
|
||||
reject('Cannot have maxSide and another dimension set together');
|
||||
}
|
||||
// Make sure the asked max size is not more than whatever
|
||||
// Services nodes can handle (MAX_ATTACHMENT_FILESIZE_BYTES)
|
||||
const askedMaxSize = maxMeasurements?.maxSize || MAX_ATTACHMENT_FILESIZE_BYTES;
|
||||
const maxSize =
|
||||
askedMaxSize > MAX_ATTACHMENT_FILESIZE_BYTES ? MAX_ATTACHMENT_FILESIZE_BYTES : askedMaxSize;
|
||||
const makeSquare = Boolean(maxMeasurements?.maxSide);
|
||||
const maxHeight =
|
||||
maxMeasurements?.maxHeight || maxMeasurements?.maxSide || ATTACHMENT_DEFAULT_MAX_SIDE;
|
||||
const maxWidth =
|
||||
maxMeasurements?.maxWidth || maxMeasurements?.maxSide || ATTACHMENT_DEFAULT_MAX_SIDE;
|
||||
|
||||
const maxSize =
|
||||
maxMeasurements?.maxSize || Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES;
|
||||
const makeSquare = Boolean(maxMeasurements?.maxSide);
|
||||
const maxHeight =
|
||||
maxMeasurements?.maxHeight || maxMeasurements?.maxSide || ATTACHMENT_DEFAULT_MAX_SIDE;
|
||||
const maxWidth =
|
||||
maxMeasurements?.maxWidth || maxMeasurements?.maxSide || ATTACHMENT_DEFAULT_MAX_SIDE;
|
||||
if (blob.type === IMAGE_GIF && blob.size <= maxSize) {
|
||||
return attachment;
|
||||
}
|
||||
|
||||
if (
|
||||
img.naturalWidth <= maxWidth &&
|
||||
img.naturalHeight <= maxHeight &&
|
||||
file.size <= maxSize &&
|
||||
!makeSquare
|
||||
) {
|
||||
resolve(attachment);
|
||||
return;
|
||||
}
|
||||
if (blob.type === IMAGE_GIF && blob.size > maxSize) {
|
||||
throw new Error(`GIF is too large, required size is ${maxSize}`);
|
||||
}
|
||||
|
||||
if (
|
||||
file.type === 'image/gif' &&
|
||||
file.size <= Constants.CONVERSATION.MAX_ATTACHMENT_FILESIZE_BYTES
|
||||
) {
|
||||
resolve(attachment);
|
||||
return;
|
||||
}
|
||||
const crop: CropOptions = {
|
||||
crop: makeSquare,
|
||||
};
|
||||
|
||||
if (file.type === 'image/gif') {
|
||||
reject(new Error('GIF is too large'));
|
||||
return;
|
||||
}
|
||||
const loadImgOpts: LoadImageOptions = {
|
||||
maxWidth: makeSquare ? maxMeasurements?.maxSide : maxWidth,
|
||||
maxHeight: makeSquare ? maxMeasurements?.maxSide : maxHeight,
|
||||
...crop,
|
||||
canvas: true,
|
||||
};
|
||||
|
||||
const canvas = (loadImage as any).scale(img, {
|
||||
canvas: true,
|
||||
maxWidth: makeSquare ? maxMeasurements?.maxSide : maxWidth,
|
||||
maxHeight: makeSquare ? maxMeasurements?.maxSide : maxHeight,
|
||||
crop: makeSquare,
|
||||
});
|
||||
let quality = 0.95;
|
||||
let i = 4;
|
||||
let blob;
|
||||
do {
|
||||
i -= 1;
|
||||
blob = window.dataURLToBlobSync(canvas.toDataURL('image/jpeg', quality));
|
||||
quality = (quality * maxSize) / blob.size;
|
||||
const canvas = await loadImage(blob, loadImgOpts);
|
||||
|
||||
if (quality > 1) {
|
||||
quality = 0.95;
|
||||
}
|
||||
} while (i > 0 && blob.size > maxSize);
|
||||
if (!canvas || !canvas.originalWidth || !canvas.originalHeight) {
|
||||
throw new Error('failed to scale image');
|
||||
}
|
||||
|
||||
resolve({
|
||||
...attachment,
|
||||
file: new File([blob], 'blob-file'),
|
||||
});
|
||||
let readAndResizedBlob = blob;
|
||||
|
||||
if (
|
||||
canvas.originalWidth <= maxWidth &&
|
||||
canvas.originalHeight <= maxHeight &&
|
||||
blob.size <= maxSize &&
|
||||
!makeSquare
|
||||
) {
|
||||
readAndResizedBlob = dataURLToBlob(
|
||||
(canvas.image as HTMLCanvasElement).toDataURL('image/jpeg', 1)
|
||||
);
|
||||
|
||||
// the canvas has a size of whatever was given by the caller of autoscale().
|
||||
// so we have to return those measures as the loaded file has now those measures.
|
||||
return {
|
||||
...attachment,
|
||||
width: canvas.image.width,
|
||||
height: canvas.image.height,
|
||||
blob,
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
let quality = 0.95;
|
||||
let i = 4;
|
||||
do {
|
||||
i -= 1;
|
||||
readAndResizedBlob = dataURLToBlob(
|
||||
(canvas.image as HTMLCanvasElement).toDataURL('image/jpeg', quality)
|
||||
);
|
||||
|
||||
quality = (quality * maxSize) / readAndResizedBlob.size;
|
||||
|
||||
if (quality > 1) {
|
||||
quality = 0.95;
|
||||
}
|
||||
} while (i > 0 && readAndResizedBlob.size > maxSize);
|
||||
|
||||
if (readAndResizedBlob.size > maxSize) {
|
||||
throw new Error('Cannot add this attachment even after trying to scale it down.');
|
||||
}
|
||||
return {
|
||||
contentType: attachment.contentType,
|
||||
blob: readAndResizedBlob,
|
||||
|
||||
width: canvas.image.width,
|
||||
height: canvas.image.height,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFile(attachment: StagedAttachmentType, maxMeasurements?: MaxScaleSize) {
|
||||
export async function getFileAndStoreLocally(
|
||||
attachment: StagedAttachmentType
|
||||
): Promise<(StagedAttachmentType & { flags?: number }) | null> {
|
||||
if (!attachment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maxMeasurements: MaxScaleSize = {
|
||||
maxSize: MAX_ATTACHMENT_FILESIZE_BYTES,
|
||||
};
|
||||
|
||||
const attachmentFlags = attachment.isVoiceMessage
|
||||
? SignalService.AttachmentPointer.Flags.VOICE_MESSAGE
|
||||
? (SignalService.AttachmentPointer.Flags.VOICE_MESSAGE as number)
|
||||
: null;
|
||||
|
||||
const scaled = await autoScale(attachment, maxMeasurements);
|
||||
const fileRead = await readFile(scaled);
|
||||
const blob: Blob = attachment.file;
|
||||
|
||||
const scaled = await autoScale(
|
||||
{
|
||||
...attachment,
|
||||
blob,
|
||||
},
|
||||
maxMeasurements
|
||||
);
|
||||
|
||||
const attachmentSavedLocally = await processNewAttachment({
|
||||
data: await scaled.blob.arrayBuffer(),
|
||||
contentType: attachment.contentType,
|
||||
});
|
||||
|
||||
console.warn('attachmentSavedLocally', attachmentSavedLocally);
|
||||
|
||||
return {
|
||||
caption: attachment.caption,
|
||||
...fileRead,
|
||||
url: undefined,
|
||||
flags: attachmentFlags || null,
|
||||
contentType: attachment.contentType,
|
||||
fileName: attachment.fileName,
|
||||
file: new File([blob], 'getFile-blob'),
|
||||
fileSize: null,
|
||||
url: '',
|
||||
path: attachmentSavedLocally.path,
|
||||
width: scaled.width,
|
||||
height: scaled.height,
|
||||
screenshot: null,
|
||||
thumbnail: null,
|
||||
size: scaled.blob.size,
|
||||
|
||||
// url: undefined,
|
||||
flags: attachmentFlags || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -126,22 +255,12 @@ export type AttachmentFileType = {
|
|||
size: number;
|
||||
};
|
||||
|
||||
// export async function readFile(attachment: any): Promise<object> {
|
||||
export async function readFile(attachment: any): Promise<AttachmentFileType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const FR = new FileReader();
|
||||
FR.onload = e => {
|
||||
const data = e?.target?.result as ArrayBuffer;
|
||||
resolve({
|
||||
...attachment,
|
||||
data,
|
||||
size: data.byteLength,
|
||||
});
|
||||
};
|
||||
FR.onerror = reject;
|
||||
FR.onabort = reject;
|
||||
FR.readAsArrayBuffer(attachment.file);
|
||||
});
|
||||
export async function readAvatarAttachment(attachment: {
|
||||
file: Blob;
|
||||
}): Promise<AttachmentFileType> {
|
||||
const dataReadFromBlob = await attachment.file.arrayBuffer();
|
||||
|
||||
return { attachment, data: dataReadFromBlob, size: dataReadFromBlob.byteLength };
|
||||
}
|
||||
|
||||
export const saveAttachmentToDisk = async ({
|
||||
|
@ -159,7 +278,7 @@ export const saveAttachmentToDisk = async ({
|
|||
save({
|
||||
attachment: { ...attachment, url: decryptedUrl },
|
||||
document,
|
||||
getAbsolutePath: window.Signal.Migrations.getAbsoluteAttachmentPath,
|
||||
getAbsolutePath: getAbsoluteAttachmentPath,
|
||||
timestamp: messageTimestamp,
|
||||
});
|
||||
await sendDataExtractionNotification(conversationId, messageSender, messageTimestamp);
|
||||
|
|
|
@ -66,8 +66,7 @@ declare global {
|
|||
getConversations: () => ConversationCollection;
|
||||
profileImages: any;
|
||||
MediaRecorder: any;
|
||||
dataURLToBlobSync: any;
|
||||
autoOrientImage: (fileOrBlobOrURL: string | File | Blob, options: any = {}) => Promise<string>;
|
||||
|
||||
contextMenuShown: boolean;
|
||||
inboxStore?: Store;
|
||||
openConversationWithMessages: (args: {
|
||||
|
|
53
yarn.lock
53
yarn.lock
|
@ -2217,18 +2217,10 @@ biskviit@1.0.1:
|
|||
dependencies:
|
||||
psl "^1.1.7"
|
||||
|
||||
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"
|
||||
integrity sha1-vPEwUspURj8w+fx+lbmkdjCpSSE=
|
||||
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"
|
||||
|
@ -2249,10 +2241,10 @@ bluebird@^3.5.5:
|
|||
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
|
||||
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
|
||||
|
||||
blueimp-canvas-to-blob@3.14.0:
|
||||
version "3.14.0"
|
||||
resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.14.0.tgz#ea075ffbfb1436607b0c75e951fb1ceb3ca0288e"
|
||||
integrity sha512-i6I2CiX1VR8YwUNYBo+dM8tg89ns4TTHxSpWjaDeHKcYS3yFalpLCwDaY21/EsJMufLy2tnG4j0JN5L8OVNkKQ==
|
||||
blueimp-canvas-to-blob@^3.29.0:
|
||||
version "3.29.0"
|
||||
resolved "https://registry.yarnpkg.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.29.0.tgz#d965f06cb1a67fdae207a2be56683f55ef531466"
|
||||
integrity sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==
|
||||
|
||||
blueimp-load-image@5.14.0:
|
||||
version "5.14.0"
|
||||
|
@ -4376,6 +4368,11 @@ file-sync-cmp@^0.1.0:
|
|||
resolved "https://registry.yarnpkg.com/file-sync-cmp/-/file-sync-cmp-0.1.1.tgz#a5e7a8ffbfa493b43b923bbd4ca89a53b63b612b"
|
||||
integrity sha1-peeo/7+kk7Q7kju9TKiaU7Y7YSs=
|
||||
|
||||
file-type@^10.10.0:
|
||||
version "10.11.0"
|
||||
resolved "https://registry.yarnpkg.com/file-type/-/file-type-10.11.0.tgz#2961d09e4675b9fb9a3ee6b69e9cd23f43fd1890"
|
||||
integrity sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
|
||||
|
@ -5321,10 +5318,12 @@ ignore@^3.3.3:
|
|||
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
|
||||
integrity sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==
|
||||
|
||||
immediate@~3.0.5:
|
||||
version "3.0.6"
|
||||
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
|
||||
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
|
||||
image-type@^4.1.0:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/image-type/-/image-type-4.1.0.tgz#72a88d64ff5021371ed67b9a466442100be57cd1"
|
||||
integrity sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==
|
||||
dependencies:
|
||||
file-type "^10.10.0"
|
||||
|
||||
immer@^7.0.3:
|
||||
version "7.0.14"
|
||||
|
@ -6166,13 +6165,6 @@ libsodium@0.7.8:
|
|||
resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.8.tgz#fbd12247b7b1353f88d8de1cbc66bc1a07b2e008"
|
||||
integrity sha512-/Qc+APf0jbeWSaeEruH0L1/tbbT+sbf884ZL0/zV/0JXaDPBzYkKbyb/wmxMHgAHzm3t6gqe7bOOXAVwfqVikQ==
|
||||
|
||||
lie@*:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/lie/-/lie-3.3.0.tgz#dcf82dee545f46074daf200c7c1c5a08e0f40f6a"
|
||||
integrity sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==
|
||||
dependencies:
|
||||
immediate "~3.0.5"
|
||||
|
||||
lines-and-columns@^1.1.6:
|
||||
version "1.1.6"
|
||||
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
|
||||
|
@ -6904,13 +6896,6 @@ nanomatch@^1.2.9:
|
|||
snapdragon "^0.8.1"
|
||||
to-regex "^3.0.1"
|
||||
|
||||
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"
|
||||
integrity sha1-yHDuC6C/D/ETUFldIWz+poptgIY=
|
||||
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"
|
||||
|
|
Loading…
Reference in New Issue