From b69ad7db16f9b736f9563b824c037794c97d4748 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Fri, 26 Jun 2020 11:47:29 +1000 Subject: [PATCH 1/7] Add attachment util --- js/modules/loki_app_dot_net_api.d.ts | 16 +++- js/modules/loki_app_dot_net_api.js | 28 +++++-- js/modules/loki_message_api.d.ts | 7 +- js/modules/loki_public_chat_api.d.ts | 19 +++-- ts/components/conversation/AddMentions.tsx | 1 - .../outgoing/content/data/ChatMessage.ts | 2 +- ts/session/sending/MessageSender.ts | 6 +- ts/session/utils/Attachments.ts | 82 +++++++++++++++++++ .../session/messages/OpenGroupMessage_test.ts | 2 +- ts/test/session/sending/MessageSender_test.ts | 53 ++++++------ ts/window.d.ts | 11 ++- 11 files changed, 178 insertions(+), 49 deletions(-) create mode 100644 ts/session/utils/Attachments.ts diff --git a/js/modules/loki_app_dot_net_api.d.ts b/js/modules/loki_app_dot_net_api.d.ts index cdb7106e4..f578c40ab 100644 --- a/js/modules/loki_app_dot_net_api.d.ts +++ b/js/modules/loki_app_dot_net_api.d.ts @@ -4,13 +4,21 @@ import { Preview, } from '../../ts/session/messages/outgoing'; -declare class LokiAppDotNetServerAPI { - constructor(ourKey: string, url: string); +interface UploadResponse { + url: string; + id?: number; +} + +export interface LokiAppDotNetServerInterface { findOrCreateChannel( api: LokiPublicChatFactoryAPI, channelId: number, conversationId: string ): Promise; + uploadData(data: FormData): Promise; + uploadAvatar(data: FormData): Promise; + putAttachment(data: ArrayBuffer): Promise; + putAvatar(data: ArrayBuffer): Promise; } export interface LokiPublicChannelAPI { @@ -25,4 +33,8 @@ export interface LokiPublicChannelAPI { ): Promise; } +declare class LokiAppDotNetServerAPI implements LokiAppDotNetServerInterface { + constructor(ourKey: string, url: string); +} + export default LokiAppDotNetServerAPI; diff --git a/js/modules/loki_app_dot_net_api.js b/js/modules/loki_app_dot_net_api.js index f20537d6a..dd7217053 100644 --- a/js/modules/loki_app_dot_net_api.js +++ b/js/modules/loki_app_dot_net_api.js @@ -1166,8 +1166,7 @@ class LokiAppDotNetServerAPI { ); if (statusCode !== 200) { - log.warn('Failed to upload avatar to fileserver'); - return null; + throw new Error(`Failed to upload avatar to ${this.baseServerUrl}`); } const url = @@ -1175,10 +1174,14 @@ class LokiAppDotNetServerAPI { response.data.avatar_image && response.data.avatar_image.url; + if (!url) { + throw new Error(`Failed to upload data: Invalid url.`); + } + // We don't use the server id for avatars return { url, - id: null, + id: undefined, }; } @@ -1195,12 +1198,16 @@ class LokiAppDotNetServerAPI { options ); if (statusCode !== 200) { - log.warn('Failed to upload data to server', this.baseServerUrl); - return null; + throw new Error(`Failed to upload data to server: ${this.baseServerUrl}`); } const url = response.data && response.data.url; const id = response.data && response.data.id; + + if (!url || !id) { + throw new Error(`Failed to upload data: Invalid url or id returned.`); + } + return { url, id, @@ -1221,6 +1228,17 @@ class LokiAppDotNetServerAPI { return this.uploadData(formData); } + + putAvatar(buf) { + const formData = new FormData(); + const buffer = Buffer.from(buf); + formData.append('avatar', buffer, { + contentType: 'application/octet-stream', + name: 'avatar', + filename: 'attachment', + }); + return this.uploadAvatar(formData); + } } // functions to a specific ADN channel on an ADN server diff --git a/js/modules/loki_message_api.d.ts b/js/modules/loki_message_api.d.ts index a19f48037..44ab557d2 100644 --- a/js/modules/loki_message_api.d.ts +++ b/js/modules/loki_message_api.d.ts @@ -1,5 +1,4 @@ -declare class LokiMessageAPI { - constructor(ourKey: string); +export interface LokiMessageInterface { sendMessage( pubKey: string, data: Uint8Array, @@ -8,4 +7,8 @@ declare class LokiMessageAPI { ): Promise; } +declare class LokiMessageAPI implements LokiMessageInterface { + constructor(ourKey: string); +} + export default LokiMessageAPI; diff --git a/js/modules/loki_public_chat_api.d.ts b/js/modules/loki_public_chat_api.d.ts index aedd87b28..b4795a523 100644 --- a/js/modules/loki_public_chat_api.d.ts +++ b/js/modules/loki_public_chat_api.d.ts @@ -1,13 +1,22 @@ -import { LokiPublicChannelAPI } from './loki_app_dot_net_api'; +import { + LokiAppDotNetServerInterface, + LokiPublicChannelAPI, +} from './loki_app_dot_net_api'; -declare class LokiPublicChatFactoryAPI { - constructor(ourKey: string); - findOrCreateServer(url: string): Promise; +export interface LokiPublicChatFactoryInterface { + ourKey: string; + findOrCreateServer(url: string): Promise; findOrCreateChannel( url: string, channelId: number, conversationId: string - ): Promise; + ): Promise; + getListOfMembers(): Promise>; +} + +declare class LokiPublicChatFactoryAPI + implements LokiPublicChatFactoryInterface { + constructor(ourKey: string); } export default LokiPublicChatFactoryAPI; diff --git a/ts/components/conversation/AddMentions.tsx b/ts/components/conversation/AddMentions.tsx index 8c9da5661..ae7a50dde 100644 --- a/ts/components/conversation/AddMentions.tsx +++ b/ts/components/conversation/AddMentions.tsx @@ -5,7 +5,6 @@ import classNames from 'classnames'; declare global { interface Window { - lokiPublicChatAPI: any; shortenPubkey: any; pubkeyPattern: any; getConversations: any; diff --git a/ts/session/messages/outgoing/content/data/ChatMessage.ts b/ts/session/messages/outgoing/content/data/ChatMessage.ts index a82cdb37a..47e9f8eaa 100644 --- a/ts/session/messages/outgoing/content/data/ChatMessage.ts +++ b/ts/session/messages/outgoing/content/data/ChatMessage.ts @@ -10,7 +10,7 @@ export interface AttachmentPointer { size?: number; thumbnail?: Uint8Array; digest?: Uint8Array; - filename?: string; + fileName?: string; flags?: number; width?: number; height?: number; diff --git a/ts/session/sending/MessageSender.ts b/ts/session/sending/MessageSender.ts index d5a33a0e0..90a124b77 100644 --- a/ts/session/sending/MessageSender.ts +++ b/ts/session/sending/MessageSender.ts @@ -107,12 +107,16 @@ export async function sendToOpenGroup( group.conversationId ); + if (!channelAPI) { + return false; + } + // Don't think returning true/false on `sendMessage` is a good way return channelAPI.sendMessage( { quote, attachments: attachments || [], - preview, + preview: preview || [], body, }, timestamp diff --git a/ts/session/utils/Attachments.ts b/ts/session/utils/Attachments.ts new file mode 100644 index 000000000..e9378f294 --- /dev/null +++ b/ts/session/utils/Attachments.ts @@ -0,0 +1,82 @@ +import * as crypto from 'crypto'; +import { Attachment } from '../../types/Attachment'; +import { OpenGroup } from '../types'; +import { AttachmentPointer } from '../messages/outgoing'; +import { LokiAppDotNetServerInterface } from '../../../js/modules/loki_app_dot_net_api'; + +interface UploadParams { + attachment: Attachment; + openGroup?: OpenGroup; + isAvatar?: boolean; + isRaw?: boolean; +} + +// tslint:disable-next-line: no-unnecessary-class +export class Attachments { + private constructor() {} + + public static getDefaultServer(): LokiAppDotNetServerInterface { + return window.tokenlessFileServerAdnAPI; + } + + public static async upload(params: UploadParams): Promise { + const { attachment, openGroup, isAvatar = false, isRaw = false } = params; + if (typeof attachment !== 'object' || attachment == null) { + throw new Error('Invalid attachment passed.'); + } + + if (!(attachment.data instanceof ArrayBuffer)) { + throw new TypeError( + `\`attachment.data\` must be an \`ArrayBuffer\`; got: ${typeof attachment.data}` + ); + } + + let server = this.getDefaultServer(); + if (openGroup) { + const openGroupServer = await window.lokiPublicChatAPI.findOrCreateServer( + openGroup.server + ); + if (!openGroupServer) { + throw new Error( + `Failed to get open group server: ${openGroup.server}.` + ); + } + server = openGroupServer; + } + + const pointer: AttachmentPointer = { + contentType: attachment.contentType + ? (attachment.contentType as string) + : undefined, + size: attachment.size, + fileName: attachment.fileName, + flags: attachment.flags, + }; + + let attachmentData: ArrayBuffer; + + if (isRaw || openGroup) { + attachmentData = attachment.data; + } else { + server = this.getDefaultServer(); + pointer.key = new Uint8Array(crypto.randomBytes(64)); + const iv = new Uint8Array(crypto.randomBytes(16)); + const data = await window.textsecure.crypto.encryptAttachment( + attachment.data, + pointer.key.buffer, + iv.buffer + ); + pointer.digest = data.digest; + attachmentData = data.ciphertext; + } + + const result = isAvatar + ? await server.putAvatar(attachmentData) + : await server.putAttachment(attachmentData); + + pointer.id = result.id; + pointer.url = result.url; + + return pointer; + } +} diff --git a/ts/test/session/messages/OpenGroupMessage_test.ts b/ts/test/session/messages/OpenGroupMessage_test.ts index e68e63555..c3244ca75 100644 --- a/ts/test/session/messages/OpenGroupMessage_test.ts +++ b/ts/test/session/messages/OpenGroupMessage_test.ts @@ -64,7 +64,7 @@ describe('OpenGroupMessage', () => { size: 10, thumbnail: new Uint8Array(2), digest: new Uint8Array(3), - filename: 'filename', + fileName: 'filename', flags: 0, width: 10, height: 20, diff --git a/ts/test/session/sending/MessageSender_test.ts b/ts/test/session/sending/MessageSender_test.ts index c534aef9e..b71c508a5 100644 --- a/ts/test/session/sending/MessageSender_test.ts +++ b/ts/test/session/sending/MessageSender_test.ts @@ -8,9 +8,7 @@ import { TestUtils } from '../../test-utils'; import { UserUtil } from '../../../util'; import { MessageEncrypter } from '../../../session/crypto'; import { SignalService } from '../../../protobuf'; -import LokiPublicChatFactoryAPI from '../../../../js/modules/loki_public_chat_api'; import { OpenGroupMessage } from '../../../session/messages/outgoing'; -import { LokiPublicChannelAPI } from '../../../../js/modules/loki_app_dot_net_api'; import { EncryptionType } from '../../../session/types/EncryptionType'; describe('MessageSender', () => { @@ -38,15 +36,21 @@ describe('MessageSender', () => { describe('send', () => { const ourNumber = 'ourNumber'; - let lokiMessageAPIStub: sinon.SinonStubbedInstance; + let lokiMessageAPISendStub: sinon.SinonStub< + [string, Uint8Array, number, number], + Promise + >; let encryptStub: sinon.SinonStub<[string, Uint8Array, EncryptionType]>; beforeEach(() => { // We can do this because LokiMessageAPI has a module export in it - lokiMessageAPIStub = sandbox.createStubInstance(LokiMessageAPI, { - sendMessage: sandbox.stub(), + lokiMessageAPISendStub = sandbox.stub< + [string, Uint8Array, number, number], + Promise + >(); + TestUtils.stubWindow('lokiMessageAPI', { + sendMessage: lokiMessageAPISendStub, }); - TestUtils.stubWindow('lokiMessageAPI', lokiMessageAPIStub); encryptStub = sandbox.stub(MessageEncrypter, 'encrypt').resolves({ envelopeType: SignalService.Envelope.Type.CIPHERTEXT, @@ -70,28 +74,26 @@ describe('MessageSender', () => { encryptStub.throws(new Error('Failed to encrypt.')); const promise = MessageSender.send(rawMessage); await expect(promise).is.rejectedWith('Failed to encrypt.'); - expect(lokiMessageAPIStub.sendMessage.callCount).to.equal(0); + expect(lokiMessageAPISendStub.callCount).to.equal(0); }); it('should only call lokiMessageAPI once if no errors occured', async () => { await MessageSender.send(rawMessage); - expect(lokiMessageAPIStub.sendMessage.callCount).to.equal(1); + expect(lokiMessageAPISendStub.callCount).to.equal(1); }); it('should only retry the specified amount of times before throwing', async () => { - lokiMessageAPIStub.sendMessage.throws(new Error('API error')); + lokiMessageAPISendStub.throws(new Error('API error')); const attempts = 2; const promise = MessageSender.send(rawMessage, attempts); await expect(promise).is.rejectedWith('API error'); - expect(lokiMessageAPIStub.sendMessage.callCount).to.equal(attempts); + expect(lokiMessageAPISendStub.callCount).to.equal(attempts); }); it('should not throw error if successful send occurs within the retry limit', async () => { - lokiMessageAPIStub.sendMessage - .onFirstCall() - .throws(new Error('API error')); + lokiMessageAPISendStub.onFirstCall().throws(new Error('API error')); await MessageSender.send(rawMessage, 3); - expect(lokiMessageAPIStub.sendMessage.callCount).to.equal(2); + expect(lokiMessageAPISendStub.callCount).to.equal(2); }); }); @@ -120,7 +122,7 @@ describe('MessageSender', () => { ttl, }); - const args = lokiMessageAPIStub.sendMessage.getCall(0).args; + const args = lokiMessageAPISendStub.getCall(0).args; expect(args[0]).to.equal(device); expect(args[2]).to.equal(timestamp); expect(args[3]).to.equal(ttl); @@ -143,7 +145,7 @@ describe('MessageSender', () => { ttl: 1, }); - const data = lokiMessageAPIStub.sendMessage.getCall(0).args[1]; + const data = lokiMessageAPISendStub.getCall(0).args[1]; const webSocketMessage = SignalService.WebSocketMessage.decode(data); expect(webSocketMessage.request?.body).to.not.equal( undefined, @@ -182,7 +184,7 @@ describe('MessageSender', () => { ttl: 1, }); - const data = lokiMessageAPIStub.sendMessage.getCall(0).args[1]; + const data = lokiMessageAPISendStub.getCall(0).args[1]; const webSocketMessage = SignalService.WebSocketMessage.decode(data); expect(webSocketMessage.request?.body).to.not.equal( undefined, @@ -211,12 +213,13 @@ describe('MessageSender', () => { describe('sendToOpenGroup', () => { it('should send the message to the correct server and channel', async () => { // We can do this because LokiPublicChatFactoryAPI has a module export in it - const stub = sandbox.createStubInstance(LokiPublicChatFactoryAPI, { - findOrCreateChannel: sandbox.stub().resolves({ - sendMessage: sandbox.stub(), - } as LokiPublicChannelAPI) as any, + const stub = sandbox.stub().resolves({ + sendMessage: sandbox.stub(), + }); + + TestUtils.stubWindow('lokiPublicChatAPI', { + findOrCreateChannel: stub, }); - TestUtils.stubWindow('lokiPublicChatAPI', stub); const group = { server: 'server', @@ -231,11 +234,7 @@ describe('MessageSender', () => { await MessageSender.sendToOpenGroup(message); - const [ - server, - channel, - conversationId, - ] = stub.findOrCreateChannel.getCall(0).args; + const [server, channel, conversationId] = stub.getCall(0).args; expect(server).to.equal(group.server); expect(channel).to.equal(group.channel); expect(conversationId).to.equal(group.conversationId); diff --git a/ts/window.d.ts b/ts/window.d.ts index be0f5e570..2b5254153 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -1,9 +1,11 @@ import { LocalizerType } from '../types/Util'; -import LokiMessageAPI from '../../js/modules/loki_message_api'; -import LokiPublicChatFactoryAPI from '../../js/modules/loki_public_chat_api'; +import { LokiMessageAPIInterface } from '../../js/modules/loki_message_api'; import { LibsignalProtocol } from '../../libtextsecure/libsignal-protocol'; import { SignalInterface } from '../../js/modules/signal'; import { Libloki } from '../libloki'; +import { LokiPublicChatFactoryInterface } from '../js/modules/loki_public_chat_api'; +import { LokiAppDotNetServerInterface } from '../js/modules/loki_app_dot_net_api'; +import { LokiMessageInterface } from '../js/modules/loki_message_api'; /* We declare window stuff here instead of global.d.ts because we are importing other declarations. @@ -48,8 +50,8 @@ declare global { log: any; lokiFeatureFlags: any; lokiFileServerAPI: LokiFileServerInstance; - lokiMessageAPI: LokiMessageAPI; - lokiPublicChatAPI: LokiPublicChatFactoryAPI; + lokiMessageAPI: LokiMessageInterface; + lokiPublicChatAPI: LokiPublicChatFactoryInterface; mnemonic: any; onLogin: any; passwordUtil: any; @@ -71,6 +73,7 @@ declare global { toggleMenuBar: any; toggleSpellCheck: any; toggleTheme: any; + tokenlessFileServerAdnAPI: LokiAppDotNetServerInterface; userConfig: any; versionInfo: any; } From 366ccdf97dd912a99a0fd67a7e16c58397e8d16b Mon Sep 17 00:00:00 2001 From: Mikunj Date: Fri, 26 Jun 2020 12:33:47 +1000 Subject: [PATCH 2/7] Add more functions --- ts/session/utils/Attachments.ts | 82 ++++++++++++++++++++++++++++++++- ts/session/utils/index.ts | 1 + 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/ts/session/utils/Attachments.ts b/ts/session/utils/Attachments.ts index e9378f294..37b8aca43 100644 --- a/ts/session/utils/Attachments.ts +++ b/ts/session/utils/Attachments.ts @@ -1,7 +1,12 @@ import * as crypto from 'crypto'; import { Attachment } from '../../types/Attachment'; import { OpenGroup } from '../types'; -import { AttachmentPointer } from '../messages/outgoing'; +import { + AttachmentPointer, + Preview, + Quote, + QuotedAttachment, +} from '../messages/outgoing'; import { LokiAppDotNetServerInterface } from '../../../js/modules/loki_app_dot_net_api'; interface UploadParams { @@ -11,8 +16,27 @@ interface UploadParams { isRaw?: boolean; } +interface RawPreview { + url?: string; + title?: string; + image: Attachment; +} + +interface RawQuoteAttachment { + contentType?: string; + fileName?: string; + thumbnail?: Attachment; +} + +interface RawQuote { + id?: number; + author?: string; + text?: string; + attachments?: Array; +} + // tslint:disable-next-line: no-unnecessary-class -export class Attachments { +export class AttachmentUtils { private constructor() {} public static getDefaultServer(): LokiAppDotNetServerInterface { @@ -79,4 +103,58 @@ export class Attachments { return pointer; } + + public static async uploadAttachments( + attachments: Array, + openGroup?: OpenGroup + ): Promise> { + const promises = attachments.map(async attachment => + this.upload({ + attachment, + openGroup, + }) + ); + + return Promise.all(promises); + } + + public static async uploadLinkPreviews( + previews: Array, + openGroup?: OpenGroup + ): Promise> { + const promises = previews.map(async item => ({ + ...item, + image: await this.upload({ + attachment: item.image, + openGroup, + }), + })); + return Promise.all(promises); + } + + public static async uploadQuoteThumbnails( + quote: RawQuote, + openGroup?: OpenGroup + ): Promise { + const promises = (quote.attachments ?? []).map(async attachment => { + let thumbnail: AttachmentPointer | undefined; + if (attachment.thumbnail) { + thumbnail = await this.upload({ + attachment: attachment.thumbnail, + openGroup, + }); + } + return { + ...attachment, + thumbnail, + } as QuotedAttachment; + }); + + const attachments = await Promise.all(promises); + + return { + ...quote, + attachments, + }; + } } diff --git a/ts/session/utils/index.ts b/ts/session/utils/index.ts index dde73e928..4089c585c 100644 --- a/ts/session/utils/index.ts +++ b/ts/session/utils/index.ts @@ -4,6 +4,7 @@ import * as SyncMessageUtils from './SyncMessageUtils'; import * as StringUtils from './String'; import * as PromiseUtils from './Promise'; +export * from './Attachments'; export * from './TypedEmitter'; export * from './JobQueue'; From c589f4a4af399d7dc80ebe9848323f702f7bae5f Mon Sep 17 00:00:00 2001 From: Mikunj Date: Mon, 29 Jun 2020 14:17:05 +1000 Subject: [PATCH 3/7] Finish hooking up attachments --- js/models/conversations.js | 203 +++++++++++++------------ js/models/messages.js | 259 ++++++++++++++++++-------------- ts/session/utils/Attachments.ts | 12 +- 3 files changed, 256 insertions(+), 218 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index 6e3dbd861..778117b57 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1191,6 +1191,18 @@ }; }, + getOpenGroup() { + if (!this.isPublic()) { + return undefined; + } + + return new libsession.Types.OpenGroup({ + server: this.get('server'), + channel: this.get('channelId'), + conversationId: this.id, + }); + }, + async sendMessage( body, attachments, @@ -1291,122 +1303,113 @@ return null; } - const attachmentsWithData = await Promise.all( - messageWithSchema.attachments.map(loadAttachmentData) - ); - - const { - body: messageBody, - attachments: finalAttachments, - } = Whisper.Message.getLongMessageAttachment({ - body, - attachments: attachmentsWithData, - now, - }); - - // FIXME audric add back profileKey - const chatMessage = new libsession.Messages.Outgoing.ChatMessage({ - body: messageBody, - timestamp: Date.now(), - attachments: finalAttachments, - expireTimer, - preview, - quote, - }); - // Start handle ChatMessages (attachments/quote/preview/body) - // FIXME AUDRIC handle attachments, quote, preview, profileKey + try { + const uploads = await message.uploadData(); - if (this.isMe()) { - await message.markMessageSyncOnly(); - // sending is done in the 'private' case below - } - const options = {}; - - options.messageType = message.get('type'); - options.isPublic = this.isPublic(); - if (this.isPublic()) { - // FIXME audric add back attachments, quote, preview - const openGroup = { - server: this.get('server'), - channel: this.get('channelId'), - conversationId: this.id, - }; - const openGroupParams = { - body, + // FIXME audric add back profileKey + const chatMessage = new libsession.Messages.Outgoing.ChatMessage({ + body: uploads.body, timestamp: Date.now(), - group: openGroup, - }; - const openGroupMessage = new libsession.Messages.Outgoing.OpenGroupMessage( - openGroupParams - ); - await libsession.getMessageQueue().sendToGroup(openGroupMessage); - - return null; - } + attachments: uploads.attachments, + expireTimer, + preview: uploads.preview, + quote: uploads.quote, + }); - options.sessionRestoration = sessionRestoration; - const destinationPubkey = new libsession.Types.PubKey(destination); - // Handle Group Invitation Message - if (groupInvitation) { - if (conversationType !== Message.PRIVATE) { - window.console.warning('Cannot send groupInvite to group chat'); + if (this.isMe()) { + await message.markMessageSyncOnly(); + // sending is done in the 'private' case below + } + const options = {}; + + options.messageType = message.get('type'); + options.isPublic = this.isPublic(); + if (this.isPublic()) { + const openGroup = this.getOpenGroup(); + + const openGroupParams = { + body, + timestamp: Date.now(), + group: openGroup, + }; + const openGroupMessage = new libsession.Messages.Outgoing.OpenGroupMessage( + openGroupParams + ); + await libsession.getMessageQueue().sendToGroup(openGroupMessage); return null; } - const groupInvitMessage = new libsession.Messages.Outgoing.GroupInvitationMessage( - { - serverName: groupInvitation.name, - channelId: groupInvitation.channelId, - serverAddress: groupInvitation.address, - } - ); - - return libsession - .getMessageQueue() - .sendUsingMultiDevice(destinationPubkey, groupInvitMessage); - } + options.sessionRestoration = sessionRestoration; + const destinationPubkey = new libsession.Types.PubKey(destination); + // Handle Group Invitation Message + if (groupInvitation) { + if (conversationType !== Message.PRIVATE) { + window.console.warning('Cannot send groupInvite to group chat'); - if (conversationType === Message.PRIVATE) { - return libsession - .getMessageQueue() - .sendUsingMultiDevice(destinationPubkey, chatMessage); - } + return null; + } - if (conversationType === Message.GROUP) { - if (this.isMediumGroup()) { - const mediumGroupChatMessage = new libsession.Messages.Outgoing.MediumGroupChatMessage( + const groupInvitMessage = new libsession.Messages.Outgoing.GroupInvitationMessage( { - chatMessage, - groupId: destination, + serverName: groupInvitation.name, + channelId: groupInvitation.channelId, + serverAddress: groupInvitation.address, } ); - const members = this.get('members'); - await Promise.all( - members.map(async m => { - const memberPubKey = new libsession.Types.PubKey(m); - await libsession - .getMessageQueue() - .sendUsingMultiDevice(memberPubKey, mediumGroupChatMessage); - }) - ); + + return libsession + .getMessageQueue() + .sendUsingMultiDevice(destinationPubkey, groupInvitMessage); + } + + if (conversationType === Message.PRIVATE) { + return libsession + .getMessageQueue() + .sendUsingMultiDevice(destinationPubkey, chatMessage); + } + + if (conversationType === Message.GROUP) { + if (this.isMediumGroup()) { + const mediumGroupChatMessage = new libsession.Messages.Outgoing.MediumGroupChatMessage( + { + chatMessage, + groupId: destination, + } + ); + const members = this.get('members'); + await Promise.all( + members.map(async m => { + const memberPubKey = new libsession.Types.PubKey(m); + await libsession + .getMessageQueue() + .sendUsingMultiDevice(memberPubKey, mediumGroupChatMessage); + }) + ); + } else { + const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( + { + chatMessage, + groupId: destination, + } + ); + await libsession + .getMessageQueue() + .sendToGroup(closedGroupChatMessage); + } } else { - const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( - { - chatMessage, - groupId: destination, - } + throw new TypeError( + `Invalid conversation type: '${conversationType}'` ); - await libsession - .getMessageQueue() - .sendToGroup(closedGroupChatMessage); } - } else { - throw new TypeError( - `Invalid conversation type: '${conversationType}'` - ); + + return true; + } catch (e) { + log.warn('Failed to send message: ', e); + await message.saveErrors(e); + + return null; } - return true; }); }, wrapSend(promise) { diff --git a/js/models/messages.js b/js/models/messages.js index 36787bd7f..8215a4a2b 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -28,8 +28,8 @@ deleteExternalMessageFiles, getAbsoluteAttachmentPath, loadAttachmentData, - // loadQuoteData, - // loadPreviewData, + loadQuoteData, + loadPreviewData, } = window.Signal.Migrations; const { bytesFromString } = window.Signal.Crypto; @@ -991,6 +991,49 @@ }); }, + /** + * Uploads attachments, previews and quotes. + * If body is too long then it is also converted to an attachment. + * + * @returns The uploaded data which includes: body, attachments, preview and quote. + */ + async uploadData() { + // TODO: In the future it might be best if we cache the upload results if possible. + // This way we don't upload duplicated data. + + const attachmentsWithData = await Promise.all( + (this.get('attachments') || []).map(loadAttachmentData) + ); + const { + body, + attachments: finalAttachments, + } = Whisper.Message.getLongMessageAttachment({ + body: this.get('body'), + attachments: attachmentsWithData, + now: this.get('sent_at'), + }); + + const quoteWithData = await loadQuoteData(this.get('quote')); + const previewWithData = await loadPreviewData(this.get('preview')); + + const conversation = this.getConversation(); + const openGroup = conversation && conversation.getOpenGroup(); + + const { AttachmentUtils } = libsession.Utils; + const [attachments, preview, quote] = await Promise.all([ + AttachmentUtils.uploadAttachments(finalAttachments, openGroup), + AttachmentUtils.uploadLinkPreviews(previewWithData, openGroup), + AttachmentUtils.uploadQuoteThumbnails(quoteWithData, openGroup), + ]); + + return { + body, + attachments, + preview, + quote, + }; + }, + // One caller today: event handler for the 'Retry Send' entry in triple-dot menu async retrySend() { if (!textsecure.messaging) { @@ -1000,85 +1043,80 @@ this.set({ errors: null }); - const conversation = this.getConversation(); - const intendedRecipients = this.get('recipients') || []; - const successfulRecipients = this.get('sent_to') || []; - const currentRecipients = conversation.getRecipients(); + try { + const conversation = this.getConversation(); + const intendedRecipients = this.get('recipients') || []; + const successfulRecipients = this.get('sent_to') || []; + const currentRecipients = conversation.getRecipients(); - // const profileKey = conversation.get('profileSharing') - // ? storage.get('profileKey') - // : null; + // const profileKey = conversation.get('profileSharing') + // ? storage.get('profileKey') + // : null; - let recipients = _.intersection(intendedRecipients, currentRecipients); - recipients = _.without(recipients, successfulRecipients); + let recipients = _.intersection(intendedRecipients, currentRecipients); + recipients = _.without(recipients, successfulRecipients); - if (!recipients.length) { - window.log.warn('retrySend: Nobody to send to!'); - - return window.Signal.Data.saveMessage(this.attributes, { - Message: Whisper.Message, - }); - } + if (!recipients.length) { + window.log.warn('retrySend: Nobody to send to!'); - const attachmentsWithData = await Promise.all( - (this.get('attachments') || []).map(loadAttachmentData) - ); - const { body } = Whisper.Message.getLongMessageAttachment({ - body: this.get('body'), - attachments: attachmentsWithData, - now: this.get('sent_at'), - }); - // TODO add logic for attachments, quote and preview here - // don't blindly reuse the one from loadQuoteData loadPreviewData and getLongMessageAttachment. - // they have similar data structure to the ones we need - // but the main difference is that they haven't been uploaded - // so no url exists in them - // so passing it to chat message is incorrect - - // const quoteWithData = await loadQuoteData(this.get('quote')); - // const previewWithData = await loadPreviewData(this.get('preview')); - const chatMessage = new libsession.Messages.Outgoing.ChatMessage({ - body, - timestamp: this.get('sent_at'), - expireTimer: this.get('expireTimer'), - }); - // Special-case the self-send case - we send only a sync message - if (recipients.length === 1 && recipients[0] === this.OUR_NUMBER) { - this.trigger('pending'); - // FIXME audric add back profileKey - await this.markMessageSyncOnly(); - // sending is done in the private case below - } + return window.Signal.Data.saveMessage(this.attributes, { + Message: Whisper.Message, + }); + } - if (conversation.isPrivate()) { - const [number] = recipients; - const recipientPubKey = new libsession.Types.PubKey(number); - this.trigger('pending'); + const { body, attachments, preview, quote } = await this.uploadData(); - return libsession - .getMessageQueue() - .sendUsingMultiDevice(recipientPubKey, chatMessage); - } + const chatMessage = new libsession.Messages.Outgoing.ChatMessage({ + body, + timestamp: this.get('sent_at'), + expireTimer: this.get('expireTimer'), + attachments, + preview, + quote, + }); - this.trigger('pending'); - // TODO should we handle open groups message here too? and mediumgroups - // Not sure there is the concept of retrySend for those - const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( - { - chatMessage, - groupId: this.get('conversationId'), + // Special-case the self-send case - we send only a sync message + if (recipients.length === 1 && recipients[0] === this.OUR_NUMBER) { + this.trigger('pending'); + // FIXME audric add back profileKey + await this.markMessageSyncOnly(); + // sending is done in the private case below } - ); - // Because this is a partial group send, we send the message with the groupId field set, but individually - // to each recipient listed - return Promise.all( - recipients.map(async r => { - const recipientPubKey = new libsession.Types.PubKey(r); + + if (conversation.isPrivate()) { + const [number] = recipients; + const recipientPubKey = new libsession.Types.PubKey(number); + this.trigger('pending'); + return libsession .getMessageQueue() - .sendUsingMultiDevice(recipientPubKey, closedGroupChatMessage); - }) - ); + .sendUsingMultiDevice(recipientPubKey, chatMessage); + } + + this.trigger('pending'); + // TODO should we handle open groups message here too? and mediumgroups + // Not sure there is the concept of retrySend for those + const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( + { + chatMessage, + groupId: this.get('conversationId'), + } + ); + // Because this is a partial group send, we send the message with the groupId field set, but individually + // to each recipient listed + return Promise.all( + recipients.map(async r => { + const recipientPubKey = new libsession.Types.PubKey(r); + return libsession + .getMessageQueue() + .sendUsingMultiDevice(recipientPubKey, closedGroupChatMessage); + }) + ); + } catch (e) { + window.log.warn('Failed message retry send: ', e); + await this.saveErrors(e); + return null; + } }, isReplayableError(e) { return ( @@ -1102,55 +1140,48 @@ return null; } - const attachmentsWithData = await Promise.all( - (this.get('attachments') || []).map(loadAttachmentData) - ); - const { body } = Whisper.Message.getLongMessageAttachment({ - body: this.get('body'), - attachments: attachmentsWithData, - now: this.get('sent_at'), - }); - // TODO add logic for attachments, quote and preview here - // don't blindly reuse the one from loadQuoteData loadPreviewData and getLongMessageAttachment. - // they have similar data structure to the ones we need - // but the main difference is that they haven't been uploaded - // so no url exists in them - // so passing it to chat message is incorrect - // const quoteWithData = await loadQuoteData(this.get('quote')); - // const previewWithData = await loadPreviewData(this.get('preview')); - const chatMessage = new libsession.Messages.Outgoing.ChatMessage({ - body, - timestamp: this.get('sent_at'), - expireTimer: this.get('expireTimer'), - }); + try { + const { body, attachments, preview, quote } = await this.uploadData(); - // Special-case the self-send case - we send only a sync message - if (number === this.OUR_NUMBER) { - this.trigger('pending'); - await this.markMessageSyncOnly(); - // sending is done in the private case below - } - const conversation = this.getConversation(); - const recipientPubKey = new libsession.Types.PubKey(number); + const chatMessage = new libsession.Messages.Outgoing.ChatMessage({ + body, + timestamp: this.get('sent_at'), + expireTimer: this.get('expireTimer'), + attachments, + preview, + quote, + }); - if (conversation.isPrivate()) { this.trigger('pending'); + + // Special-case the self-send case - we send only a sync message + if (number === this.OUR_NUMBER) { + await this.markMessageSyncOnly(); + // sending is done in the private case below + } + const conversation = this.getConversation(); + const recipientPubKey = new libsession.Types.PubKey(number); + + if (conversation.isPrivate()) { + return libsession + .getMessageQueue() + .sendUsingMultiDevice(recipientPubKey, chatMessage); + } + + const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( + { + chatMessage, + groupId: this.get('conversationId'), + } + ); + // resend tries to send the message to that specific user only in the context of a closed group return libsession .getMessageQueue() - .sendUsingMultiDevice(recipientPubKey, chatMessage); + .sendUsingMultiDevice(recipientPubKey, closedGroupChatMessage); + } catch (e) { + await this.saveErrors(e); + return null; } - - const closedGroupChatMessage = new libsession.Messages.Outgoing.ClosedGroupChatMessage( - { - chatMessage, - groupId: this.get('conversationId'), - } - ); - // resend tries to send the message to that specific user only in the context of a closed group - this.trigger('pending'); - return libsession - .getMessageQueue() - .sendUsingMultiDevice(recipientPubKey, closedGroupChatMessage); }, removeOutgoingErrors(number) { const errors = _.partition( diff --git a/ts/session/utils/Attachments.ts b/ts/session/utils/Attachments.ts index 37b8aca43..7199d535e 100644 --- a/ts/session/utils/Attachments.ts +++ b/ts/session/utils/Attachments.ts @@ -108,7 +108,7 @@ export class AttachmentUtils { attachments: Array, openGroup?: OpenGroup ): Promise> { - const promises = attachments.map(async attachment => + const promises = (attachments || []).map(async attachment => this.upload({ attachment, openGroup, @@ -122,7 +122,7 @@ export class AttachmentUtils { previews: Array, openGroup?: OpenGroup ): Promise> { - const promises = previews.map(async item => ({ + const promises = (previews || []).map(async item => ({ ...item, image: await this.upload({ attachment: item.image, @@ -133,9 +133,13 @@ export class AttachmentUtils { } public static async uploadQuoteThumbnails( - quote: RawQuote, + quote?: RawQuote, openGroup?: OpenGroup - ): Promise { + ): Promise { + if (!quote) { + return undefined; + } + const promises = (quote.attachments ?? []).map(async attachment => { let thumbnail: AttachmentPointer | undefined; if (attachment.thumbnail) { From be4e540c6204fbffd3cc7e87d967c8e3414296a7 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Mon, 29 Jun 2020 14:21:57 +1000 Subject: [PATCH 4/7] Remove duplicate logs --- js/models/conversations.js | 1 - js/models/messages.js | 1 - 2 files changed, 2 deletions(-) diff --git a/js/models/conversations.js b/js/models/conversations.js index 778117b57..25aaaac3f 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1405,7 +1405,6 @@ return true; } catch (e) { - log.warn('Failed to send message: ', e); await message.saveErrors(e); return null; diff --git a/js/models/messages.js b/js/models/messages.js index 8215a4a2b..deb8771ca 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1113,7 +1113,6 @@ }) ); } catch (e) { - window.log.warn('Failed message retry send: ', e); await this.saveErrors(e); return null; } From 347a1e5582c720d59e0728bd6bc7e6864494b50d Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 30 Jun 2020 09:38:09 +1000 Subject: [PATCH 5/7] Add avatar uploading to attachment utils --- js/background.js | 12 +++++++----- ts/session/utils/Attachments.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/js/background.js b/js/background.js index c3b33e0ae..32f2e7cd4 100644 --- a/js/background.js +++ b/js/background.js @@ -914,11 +914,13 @@ profileKey ); - const avatarPointer = await textsecure.messaging.uploadAvatar({ - ...data, - data: encryptedData, - size: encryptedData.byteLength, - }); + const avatarPointer = await libsession.Utils.AttachmentUtils.uploadAvatar( + { + ...data, + data: encryptedData, + size: encryptedData.byteLength, + } + ); ({ url } = avatarPointer); diff --git a/ts/session/utils/Attachments.ts b/ts/session/utils/Attachments.ts index 7199d535e..a1de04c9a 100644 --- a/ts/session/utils/Attachments.ts +++ b/ts/session/utils/Attachments.ts @@ -104,6 +104,22 @@ export class AttachmentUtils { return pointer; } + public static async uploadAvatar( + attachment?: Attachment + ): Promise { + if (!attachment) { + return undefined; + } + + // isRaw is true since the data is already encrypted + // and doesn't need to be encrypted again + return this.upload({ + attachment, + isAvatar: true, + isRaw: true, + }); + } + public static async uploadAttachments( attachments: Array, openGroup?: OpenGroup From 522e9fb4ecdba1b1637629a7722ac42ff7aed1ba Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 30 Jun 2020 09:43:22 +1000 Subject: [PATCH 6/7] Remove old functions --- js/modules/web_api.js | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/js/modules/web_api.js b/js/modules/web_api.js index 0b89c3804..efe82f9ad 100644 --- a/js/modules/web_api.js +++ b/js/modules/web_api.js @@ -1,8 +1,7 @@ const fetch = require('node-fetch'); const { Agent } = require('https'); -const FormData = require('form-data'); -/* global Buffer, setTimeout, log, _, lokiFileServerAPI */ +/* global Buffer, setTimeout, log, _ */ /* eslint-disable more/no-then, no-bitwise, no-nested-ternary */ @@ -302,8 +301,6 @@ function initialize() { getAttachment, getProxiedSize, makeProxiedRequest, - putAttachment, - putAvatar, }; function getAttachment(fileUrl) { @@ -315,30 +312,6 @@ function initialize() { }); } - function putAttachment(maybeEncryptedBin) { - const formData = new FormData(); - const buffer = Buffer.from(maybeEncryptedBin); - formData.append('type', 'network.loki'); - formData.append('content', buffer, { - contentType: 'application/octet-stream', - name: 'content', - filename: 'attachment', - }); - - return lokiFileServerAPI.constructor.uploadPrivateAttachment(formData); - } - - function putAvatar(bin) { - const formData = new FormData(); - const buffer = Buffer.from(bin); - formData.append('avatar', buffer, { - contentType: 'application/octet-stream', - name: 'avatar', - filename: 'attachment', - }); - return lokiFileServerAPI.uploadAvatar(formData); - } - // eslint-disable-next-line no-shadow async function getProxiedSize(url) { const result = await _outerAjax(url, { From 225d7f84e463da5a608537d6e14bf3278ea6d87d Mon Sep 17 00:00:00 2001 From: Mikunj Date: Tue, 30 Jun 2020 09:44:39 +1000 Subject: [PATCH 7/7] Fix declaration file names --- js/models/{conversation.d.ts => conversations.d.ts} | 0 js/models/conversations.js | 4 ++-- js/models/{message.d.ts => messages.d.ts} | 0 js/models/messages.js | 2 +- ts/receiver/attachments.ts | 2 +- ts/receiver/queuedJob.ts | 4 ++-- ts/receiver/receiver.ts | 3 +-- 7 files changed, 7 insertions(+), 8 deletions(-) rename js/models/{conversation.d.ts => conversations.d.ts} (100%) rename js/models/{message.d.ts => messages.d.ts} (100%) diff --git a/js/models/conversation.d.ts b/js/models/conversations.d.ts similarity index 100% rename from js/models/conversation.d.ts rename to js/models/conversations.d.ts diff --git a/js/models/conversations.js b/js/models/conversations.js index 25aaaac3f..92f2ac674 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -1191,7 +1191,7 @@ }; }, - getOpenGroup() { + toOpenGroup() { if (!this.isPublic()) { return undefined; } @@ -1325,7 +1325,7 @@ options.messageType = message.get('type'); options.isPublic = this.isPublic(); if (this.isPublic()) { - const openGroup = this.getOpenGroup(); + const openGroup = this.toOpenGroup(); const openGroupParams = { body, diff --git a/js/models/message.d.ts b/js/models/messages.d.ts similarity index 100% rename from js/models/message.d.ts rename to js/models/messages.d.ts diff --git a/js/models/messages.js b/js/models/messages.js index deb8771ca..0b800ba4d 100644 --- a/js/models/messages.js +++ b/js/models/messages.js @@ -1017,7 +1017,7 @@ const previewWithData = await loadPreviewData(this.get('preview')); const conversation = this.getConversation(); - const openGroup = conversation && conversation.getOpenGroup(); + const openGroup = conversation && conversation.toOpenGroup(); const { AttachmentUtils } = libsession.Utils; const [attachments, preview, quote] = await Promise.all([ diff --git a/ts/receiver/attachments.ts b/ts/receiver/attachments.ts index db6a112d2..8f35e91d6 100644 --- a/ts/receiver/attachments.ts +++ b/ts/receiver/attachments.ts @@ -1,4 +1,4 @@ -import { MessageModel } from '../../js/models/message'; +import { MessageModel } from '../../js/models/messages'; // TODO: Might convert it to a class later let webAPI: any; diff --git a/ts/receiver/queuedJob.ts b/ts/receiver/queuedJob.ts index c1520c09c..7e7a9dc61 100644 --- a/ts/receiver/queuedJob.ts +++ b/ts/receiver/queuedJob.ts @@ -1,8 +1,8 @@ import { queueAttachmentDownloads } from './attachments'; import { Quote } from './types'; -import { ConversationModel } from '../../js/models/conversation'; -import { EndSessionType, MessageModel } from '../../js/models/message'; +import { ConversationModel } from '../../js/models/conversations'; +import { MessageModel } from '../../js/models/messages'; async function handleGroups( conversation: ConversationModel, diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts index ad8a9a9b2..4c34efaf9 100644 --- a/ts/receiver/receiver.ts +++ b/ts/receiver/receiver.ts @@ -5,8 +5,7 @@ import { handleMessageJob } from './queuedJob'; import { handleEndSession } from './sessionHandling'; import { handleUnpairRequest } from './multidevice'; import { EnvelopePlus } from './types'; -import { ConversationModel } from '../../js/models/conversation'; -import { EndSessionType, MessageModel } from '../../js/models/message'; +import { MessageModel } from '../../js/models/messages'; import { downloadAttachment } from './attachments'; import { handleMediumGroupUpdate } from './mediumGroups';