From b69ad7db16f9b736f9563b824c037794c97d4748 Mon Sep 17 00:00:00 2001 From: Mikunj Date: Fri, 26 Jun 2020 11:47:29 +1000 Subject: [PATCH] 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; }