Move scale/resize and attachment logic to typescript

This commit is contained in:
Audric Ackermann 2022-01-10 09:36:17 +11:00 committed by audric
parent 6992305e27
commit c7565fe7b3
56 changed files with 1360 additions and 2318 deletions

View File

@ -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

View File

@ -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'),
});

View File

@ -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
);
});
};

View File

@ -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);
}

View File

@ -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,

View File

@ -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, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont want to overburden IndexedDB
// by potentially doubling stored image data.
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
const 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 doesnt support async testing:
// https://github.com/leebyron/testcheck-js/issues/45
exports._replaceUnicodeOrderOverridesSync = attachment => {
if (!is.string(attachment.fileName)) {
return attachment;
}
const normalizedFilename = attachment.fileName.replace(
INVALID_CHARACTERS_PATTERN,
UNICODE_REPLACEMENT_CHARACTER
);
const newAttachment = 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);
}
};

View File

@ -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;
};

View File

@ -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,
};

View File

@ -1 +0,0 @@
export function toLogFormat(error: any): string;

View File

@ -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: Weve updated our definition of `initializeAttachmentMetadata`, so
// we need to run it again on existing items that have previously been incorrectly
// classified:
const toVersion7 = exports._withSchemaVersion({
schemaVersion: 7,
upgrade: initializeAttachmentMetadata,
});
const toVersion8 = exports._withSchemaVersion({
schemaVersion: 8,
upgrade: exports._mapAttachments(Attachment.captureDimensionsAndScreenshot),
});
const toVersion9 = exports._withSchemaVersion({
schemaVersion: 9,
upgrade: exports._mapAttachments(Attachment.replaceUnicodeV2),
});
const toVersion10 = exports._withSchemaVersion({
schemaVersion: 10,
upgrade: exports._mapPreviewAttachments(Attachment.migrateDataToFileSystem),
});
const VERSIONS = [
toVersion0,
toVersion1,
toVersion2,
toVersion3,
toVersion4,
toVersion5,
toVersion6,
toVersion7,
toVersion8,
toVersion9,
toVersion10,
];
exports.CURRENT_SCHEMA_VERSION = VERSIONS.length - 1;
// We need dimensions and screenshots for images for proper display
exports.VERSION_NEEDED_FOR_DISPLAY = 9;
// UpgradeStep
exports.upgradeSchema = async (
rawMessage,
{
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
maxVersion = exports.CURRENT_SCHEMA_VERSION,
} = {}
) => {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError('context.writeNewAttachmentData is required');
}
if (!isFunction(getAbsoluteAttachmentPath)) {
throw new TypeError('context.getAbsoluteAttachmentPath is required');
}
if (!isFunction(makeObjectUrl)) {
throw new TypeError('context.makeObjectUrl is required');
}
if (!isFunction(revokeObjectUrl)) {
throw new TypeError('context.revokeObjectUrl is required');
}
if (!isFunction(getImageDimensions)) {
throw new TypeError('context.getImageDimensions is required');
}
if (!isFunction(makeImageThumbnail)) {
throw new TypeError('context.makeImageThumbnail is required');
}
if (!isFunction(makeVideoScreenshot)) {
throw new TypeError('context.makeVideoScreenshot is required');
}
if (!isObject(logger)) {
throw new TypeError('context.logger is required');
}
let message = rawMessage;
// eslint-disable-next-line no-restricted-syntax
for (let index = 0, max = VERSIONS.length; index < max; index += 1) {
if (maxVersion < index) {
break;
}
const currentVersion = VERSIONS[index];
// We really do want this intra-loop await because this is a chained async action,
// each step dependent on the previous
// eslint-disable-next-line no-await-in-loop
message = await currentVersion(message, {
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
});
}
return message;
};
// Runs on attachments outside of the schema upgrade process, since attachments are
// downloaded out of band.
exports.processNewAttachment = async (
attachment,
{
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
} = {}
) => {
if (!isFunction(writeNewAttachmentData)) {
throw new TypeError('context.writeNewAttachmentData is required');
}
if (!isFunction(getAbsoluteAttachmentPath)) {
throw new TypeError('context.getAbsoluteAttachmentPath is required');
}
if (!isFunction(makeObjectUrl)) {
throw new TypeError('context.makeObjectUrl is required');
}
if (!isFunction(revokeObjectUrl)) {
throw new TypeError('context.revokeObjectUrl is required');
}
if (!isFunction(getImageDimensions)) {
throw new TypeError('context.getImageDimensions is required');
}
if (!isFunction(makeImageThumbnail)) {
throw new TypeError('context.makeImageThumbnail is required');
}
if (!isFunction(makeVideoScreenshot)) {
throw new TypeError('context.makeVideoScreenshot is required');
}
if (!isObject(logger)) {
throw new TypeError('context.logger is required');
}
const rotatedAttachment = await Attachment.autoOrientJPEG(attachment);
const onDiskAttachment = await Attachment.migrateDataToFileSystem(rotatedAttachment, {
writeNewAttachmentData,
});
const finalAttachment = await Attachment.captureDimensionsAndScreenshot(onDiskAttachment, {
writeNewAttachmentData,
getAbsoluteAttachmentPath,
makeObjectUrl,
revokeObjectUrl,
getImageDimensions,
makeImageThumbnail,
makeVideoScreenshot,
logger,
});
return finalAttachment;
};
exports.createAttachmentLoader = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError('createAttachmentLoader: loadAttachmentData is required');
}
return async message =>
Object.assign({}, message, {
attachments: await Promise.all(message.attachments.map(loadAttachmentData)),
});
};
exports.loadQuoteData = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError('loadQuoteData: loadAttachmentData is required');
}
return async quote => {
if (!quote) {
return null;
}
return {
...quote,
attachments: await Promise.all(
(quote.attachments || []).map(async attachment => {
const { thumbnail } = attachment;
if (!thumbnail || !thumbnail.path) {
return attachment;
}
return {
...attachment,
thumbnail: await loadAttachmentData(thumbnail),
};
})
),
};
};
};
exports.loadPreviewData = loadAttachmentData => {
if (!isFunction(loadAttachmentData)) {
throw new TypeError('loadPreviewData: loadAttachmentData is required');
}
return async preview => {
if (!preview || !preview.length) {
return [];
}
return Promise.all(
preview.map(async item => {
if (!item.image) {
return item;
}
return {
...item,
image: await loadAttachmentData(item.image),
};
})
);
};
};
exports.deleteAllExternalFiles = ({ deleteAttachmentData, deleteOnDisk }) => {
if (!isFunction(deleteAttachmentData)) {
throw new TypeError('deleteAllExternalFiles: deleteAttachmentData must be a function');
}
if (!isFunction(deleteOnDisk)) {
throw new TypeError('deleteAllExternalFiles: deleteOnDisk must be a function');
}
return async message => {
const { attachments, quote, contact, preview } = message;
if (attachments && attachments.length) {
await Promise.all(attachments.map(deleteAttachmentData));
}
if (quote && quote.attachments && quote.attachments.length) {
await Promise.all(
quote.attachments.map(async attachment => {
const { thumbnail } = attachment;
// To prevent spoofing, we copy the original image from the quoted message.
// If so, it will have a 'copied' field. We don't want to delete it if it has
// that field set to true.
if (thumbnail && thumbnail.path && !thumbnail.copied) {
await deleteOnDisk(thumbnail.path);
}
})
);
}
if (contact && contact.length) {
await Promise.all(
contact.map(async item => {
const { avatar } = item;
if (avatar && avatar.avatar && avatar.avatar.path) {
await deleteOnDisk(avatar.avatar.path);
}
})
);
}
if (preview && preview.length) {
await Promise.all(
preview.map(async item => {
const { image } = item;
if (image && image.path) {
await deleteOnDisk(image.path);
}
})
);
}
};
};
// createAttachmentDataWriter :: (RelativePath -> IO Unit)
// Message ->
// IO (Promise Message)
exports.createAttachmentDataWriter = ({ writeExistingAttachmentData, logger }) => {
if (!isFunction(writeExistingAttachmentData)) {
throw new TypeError(
'createAttachmentDataWriter: writeExistingAttachmentData must be a function'
);
}
if (!isObject(logger)) {
throw new TypeError('createAttachmentDataWriter: logger must be an object');
}
return async rawMessage => {
if (!exports.isValid(rawMessage)) {
throw new TypeError("'rawMessage' is not valid");
}
const message = exports.initializeSchemaVersion({
message: rawMessage,
logger,
});
const { attachments, quote, contact, preview } = message;
const hasFilesToWrite =
(quote && quote.attachments && quote.attachments.length > 0) ||
(attachments && attachments.length > 0) ||
(contact && contact.length > 0) ||
(preview && preview.length > 0);
if (!hasFilesToWrite) {
return message;
}
const lastVersionWithAttachmentDataInMemory = 2;
const willAttachmentsGoToFileSystemOnUpgrade =
message.schemaVersion <= lastVersionWithAttachmentDataInMemory;
if (willAttachmentsGoToFileSystemOnUpgrade) {
return message;
}
(attachments || []).forEach(attachment => {
if (!Attachment.hasData(attachment)) {
throw new TypeError("'attachment.data' is required during message import");
}
if (!isString(attachment.path)) {
throw new TypeError("'attachment.path' is required during message import");
}
});
const writeThumbnails = exports._mapQuotedAttachments(async thumbnail => {
const { data, path } = thumbnail;
// we want to be bulletproof to thumbnails without data
if (!data || !path) {
logger.warn(
'Thumbnail had neither data nor path.',
'id:',
message.id,
'source:',
message.source
);
return thumbnail;
}
await writeExistingAttachmentData(thumbnail);
return omit(thumbnail, ['data']);
});
const 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;
};
};

View File

@ -1,3 +0,0 @@
const { isNumber } = require('lodash');
exports.isValid = value => isNumber(value) && value >= 0;

View File

@ -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);
};

View File

@ -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": [

View File

@ -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');

View File

@ -61,10 +61,6 @@
z-index: 1;
border-radius: 8px;
}
.input-file {
display: none;
}
}
.expired {

View File

@ -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");
}

1
ts/blueimp-canvas-to-blob.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'blueimp-canvas-to-blob';

View File

@ -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()

View File

@ -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')}>

View File

@ -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) {

View File

@ -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;
}

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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);
}
};

View File

@ -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) => {

View File

@ -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 });
}
}
}

View File

@ -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

View File

@ -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);

View File

@ -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;

View File

@ -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,

View File

@ -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) {

View File

@ -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,

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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();

View File

@ -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({

View File

@ -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;
}

View File

@ -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,

View File

@ -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);
};

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View File

@ -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 });

View File

@ -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');
// });
// });
});

View File

@ -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) {

View File

@ -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;

View File

@ -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);
}
}

View File

@ -1,5 +1,4 @@
// toLogFormat :: Error -> String
exports.toLogFormat = error => {
export const toLogFormat = (error: any) => {
if (!error) {
return error;
}

View File

@ -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;
}
}

View File

@ -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, wed preserve the original image data for users who want to
// retain it but due to reports of data loss, we dont want to overburden IndexedDB
// by potentially doubling stored image data.
// See: https://github.com/signalapp/Signal-Desktop/issues/1589
// 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 doesnt 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);
}
}
};

View File

@ -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);

3
ts/window.d.ts vendored
View File

@ -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: {

View File

@ -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"