Move to websocket for requests to signal server
This commit is contained in:
parent
8449f343a6
commit
1c1d0e2da0
31 changed files with 1892 additions and 1336 deletions
|
@ -14,15 +14,9 @@ const fakeAPI = {
|
||||||
getAvatar: fakeCall,
|
getAvatar: fakeCall,
|
||||||
getDevices: fakeCall,
|
getDevices: fakeCall,
|
||||||
// getKeysForIdentifier : fakeCall,
|
// getKeysForIdentifier : fakeCall,
|
||||||
getMessageSocket: async () => ({
|
|
||||||
on() {},
|
|
||||||
removeListener() {},
|
|
||||||
close() {},
|
|
||||||
sendBytes() {},
|
|
||||||
}),
|
|
||||||
getMyKeys: fakeCall,
|
getMyKeys: fakeCall,
|
||||||
getProfile: fakeCall,
|
getProfile: fakeCall,
|
||||||
getProvisioningSocket: fakeCall,
|
getProvisioningResource: fakeCall,
|
||||||
putAttachment: fakeCall,
|
putAttachment: fakeCall,
|
||||||
registerKeys: fakeCall,
|
registerKeys: fakeCall,
|
||||||
requestVerificationSMS: fakeCall,
|
requestVerificationSMS: fakeCall,
|
||||||
|
|
|
@ -1,75 +0,0 @@
|
||||||
// Copyright 2017-2020 Signal Messenger, LLC
|
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
|
|
||||||
/* global textsecure */
|
|
||||||
|
|
||||||
describe('createTaskWithTimeout', () => {
|
|
||||||
it('resolves when promise resolves', () => {
|
|
||||||
const task = () => Promise.resolve('hi!');
|
|
||||||
const taskWithTimeout = textsecure.createTaskWithTimeout(task);
|
|
||||||
|
|
||||||
return taskWithTimeout().then(result => {
|
|
||||||
assert.strictEqual(result, 'hi!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('flows error from promise back', () => {
|
|
||||||
const error = new Error('original');
|
|
||||||
const task = () => Promise.reject(error);
|
|
||||||
const taskWithTimeout = textsecure.createTaskWithTimeout(task);
|
|
||||||
|
|
||||||
return taskWithTimeout().catch(flowedError => {
|
|
||||||
assert.strictEqual(error, flowedError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('rejects if promise takes too long (this one logs error to console)', () => {
|
|
||||||
let complete = false;
|
|
||||||
const task = () =>
|
|
||||||
new Promise(resolve => {
|
|
||||||
setTimeout(() => {
|
|
||||||
complete = true;
|
|
||||||
resolve();
|
|
||||||
}, 3000);
|
|
||||||
});
|
|
||||||
const taskWithTimeout = textsecure.createTaskWithTimeout(task, this.name, {
|
|
||||||
timeout: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
return taskWithTimeout().then(
|
|
||||||
() => {
|
|
||||||
throw new Error('it was not supposed to resolve!');
|
|
||||||
},
|
|
||||||
() => {
|
|
||||||
assert.strictEqual(complete, false);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('resolves if task returns something falsey', () => {
|
|
||||||
const task = () => {};
|
|
||||||
const taskWithTimeout = textsecure.createTaskWithTimeout(task);
|
|
||||||
return taskWithTimeout();
|
|
||||||
});
|
|
||||||
it('resolves if task returns a non-promise', () => {
|
|
||||||
const task = () => 'hi!';
|
|
||||||
const taskWithTimeout = textsecure.createTaskWithTimeout(task);
|
|
||||||
return taskWithTimeout().then(result => {
|
|
||||||
assert.strictEqual(result, 'hi!');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('rejects if task throws (and does not log about taking too long)', () => {
|
|
||||||
const error = new Error('Task is throwing!');
|
|
||||||
const task = () => {
|
|
||||||
throw error;
|
|
||||||
};
|
|
||||||
const taskWithTimeout = textsecure.createTaskWithTimeout(task, this.name, {
|
|
||||||
timeout: 10,
|
|
||||||
});
|
|
||||||
return taskWithTimeout().then(
|
|
||||||
() => {
|
|
||||||
throw new Error('Overall task should reject!');
|
|
||||||
},
|
|
||||||
flowedError => {
|
|
||||||
assert.strictEqual(flowedError, error);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -107,7 +107,7 @@ try {
|
||||||
|
|
||||||
let connectStartTime = 0;
|
let connectStartTime = 0;
|
||||||
|
|
||||||
window.logMessageReceiverConnect = () => {
|
window.logAuthenticatedConnect = () => {
|
||||||
if (connectStartTime === 0) {
|
if (connectStartTime === 0) {
|
||||||
connectStartTime = Date.now();
|
connectStartTime = Date.now();
|
||||||
}
|
}
|
||||||
|
|
317
ts/background.ts
317
ts/background.ts
|
@ -135,10 +135,12 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
// Initialize WebAPI as early as possible
|
// Initialize WebAPI as early as possible
|
||||||
let server: WebAPIType | undefined;
|
let server: WebAPIType | undefined;
|
||||||
|
let messageReceiver: MessageReceiver | undefined;
|
||||||
window.storage.onready(() => {
|
window.storage.onready(() => {
|
||||||
server = window.WebAPI.connect(
|
server = window.WebAPI.connect(
|
||||||
window.textsecure.storage.user.getWebAPICredentials()
|
window.textsecure.storage.user.getWebAPICredentials()
|
||||||
);
|
);
|
||||||
|
window.textsecure.server = server;
|
||||||
|
|
||||||
window.textsecure.storage.user.on('credentialsChange', async () => {
|
window.textsecure.storage.user.on('credentialsChange', async () => {
|
||||||
strictAssert(server !== undefined, 'WebAPI not ready');
|
strictAssert(server !== undefined, 'WebAPI not ready');
|
||||||
|
@ -150,6 +152,122 @@ export async function startApp(): Promise<void> {
|
||||||
initializeAllJobQueues({
|
initializeAllJobQueues({
|
||||||
server,
|
server,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
window.log.info('Initializing MessageReceiver');
|
||||||
|
messageReceiver = new MessageReceiver({
|
||||||
|
server,
|
||||||
|
storage: window.storage,
|
||||||
|
serverTrustRoot: window.getServerTrustRoot(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-inner-declarations
|
||||||
|
function queuedEventListener<Args extends Array<unknown>>(
|
||||||
|
handler: (...args: Args) => Promise<void> | void,
|
||||||
|
track = true
|
||||||
|
): (...args: Args) => void {
|
||||||
|
return (...args: Args): void => {
|
||||||
|
eventHandlerQueue.add(async () => {
|
||||||
|
try {
|
||||||
|
await handler(...args);
|
||||||
|
} finally {
|
||||||
|
// message/sent: Message.handleDataMessage has its own queue and will
|
||||||
|
// trigger this event itself when complete.
|
||||||
|
// error: Error processing (below) also has its own queue and self-trigger.
|
||||||
|
if (track) {
|
||||||
|
window.Whisper.events.trigger('incrementProgress');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'message',
|
||||||
|
queuedEventListener(onMessageReceived, false)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'delivery',
|
||||||
|
queuedEventListener(onDeliveryReceipt)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'contact',
|
||||||
|
queuedEventListener(onContactReceived)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'contactSync',
|
||||||
|
queuedEventListener(onContactSyncComplete)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'group',
|
||||||
|
queuedEventListener(onGroupReceived)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'groupSync',
|
||||||
|
queuedEventListener(onGroupSyncComplete)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'sent',
|
||||||
|
queuedEventListener(onSentMessage, false)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'readSync',
|
||||||
|
queuedEventListener(onReadSync)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'read',
|
||||||
|
queuedEventListener(onReadReceipt)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'view',
|
||||||
|
queuedEventListener(onViewReceipt)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'verified',
|
||||||
|
queuedEventListener(onVerified)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'error',
|
||||||
|
queuedEventListener(onError, false)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'decryption-error',
|
||||||
|
queuedEventListener(onDecryptionError)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'retry-request',
|
||||||
|
queuedEventListener(onRetryRequest)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener('empty', queuedEventListener(onEmpty));
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'reconnect',
|
||||||
|
queuedEventListener(onReconnect)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'configuration',
|
||||||
|
queuedEventListener(onConfiguration)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener('typing', queuedEventListener(onTyping));
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'sticker-pack',
|
||||||
|
queuedEventListener(onStickerPack)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'viewOnceOpenSync',
|
||||||
|
queuedEventListener(onViewOnceOpenSync)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'messageRequestResponse',
|
||||||
|
queuedEventListener(onMessageRequestResponse)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'profileKeyUpdate',
|
||||||
|
queuedEventListener(onProfileKeyUpdate)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'fetchLatest',
|
||||||
|
queuedEventListener(onFetchLatestSync)
|
||||||
|
);
|
||||||
|
messageReceiver.addEventListener('keys', queuedEventListener(onKeysSync));
|
||||||
});
|
});
|
||||||
|
|
||||||
ourProfileKeyService.initialize(window.storage);
|
ourProfileKeyService.initialize(window.storage);
|
||||||
|
@ -378,16 +496,11 @@ export async function startApp(): Promise<void> {
|
||||||
window.getAccountManager().refreshPreKeys();
|
window.getAccountManager().refreshPreKeys();
|
||||||
});
|
});
|
||||||
|
|
||||||
let messageReceiver: MessageReceiver | undefined;
|
|
||||||
let preMessageReceiverStatus: SocketStatus | undefined;
|
|
||||||
window.getSocketStatus = () => {
|
window.getSocketStatus = () => {
|
||||||
if (messageReceiver) {
|
if (server === undefined) {
|
||||||
return messageReceiver.getStatus();
|
|
||||||
}
|
|
||||||
if (preMessageReceiverStatus) {
|
|
||||||
return preMessageReceiverStatus;
|
|
||||||
}
|
|
||||||
return SocketStatus.CLOSED;
|
return SocketStatus.CLOSED;
|
||||||
|
}
|
||||||
|
return server.getSocketStatus();
|
||||||
};
|
};
|
||||||
let accountManager: typeof window.textsecure.AccountManager;
|
let accountManager: typeof window.textsecure.AccountManager;
|
||||||
window.getAccountManager = () => {
|
window.getAccountManager = () => {
|
||||||
|
@ -638,15 +751,15 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
// Stop processing incoming messages
|
// Stop processing incoming messages
|
||||||
if (messageReceiver) {
|
if (messageReceiver) {
|
||||||
|
strictAssert(
|
||||||
|
server !== undefined,
|
||||||
|
'WebAPI should be initialized together with MessageReceiver'
|
||||||
|
);
|
||||||
|
server.unregisterRequestHandler(messageReceiver);
|
||||||
await messageReceiver.stopProcessing();
|
await messageReceiver.stopProcessing();
|
||||||
await window.waitForAllBatchers();
|
await window.waitForAllBatchers();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageReceiver) {
|
|
||||||
messageReceiver.unregisterBatchers();
|
|
||||||
messageReceiver = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A number of still-to-queue database queries might be waiting inside batchers.
|
// A number of still-to-queue database queries might be waiting inside batchers.
|
||||||
// We wait for these to empty first, and then shut down the data interface.
|
// We wait for these to empty first, and then shut down the data interface.
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
@ -1658,27 +1771,21 @@ export async function startApp(): Promise<void> {
|
||||||
|
|
||||||
window.Whisper.events.on('powerMonitorResume', () => {
|
window.Whisper.events.on('powerMonitorResume', () => {
|
||||||
window.log.info('powerMonitor: resume');
|
window.log.info('powerMonitor: resume');
|
||||||
if (!messageReceiver) {
|
server?.checkSockets();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
messageReceiver.checkSocket();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const reconnectToWebSocketQueue = new LatestQueue();
|
const reconnectToWebSocketQueue = new LatestQueue();
|
||||||
|
|
||||||
const enqueueReconnectToWebSocket = () => {
|
const enqueueReconnectToWebSocket = () => {
|
||||||
reconnectToWebSocketQueue.add(async () => {
|
reconnectToWebSocketQueue.add(async () => {
|
||||||
if (!messageReceiver) {
|
if (!server) {
|
||||||
window.log.info(
|
window.log.info('reconnectToWebSocket: No server. Early return.');
|
||||||
'reconnectToWebSocket: No messageReceiver. Early return.'
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.log.info('reconnectToWebSocket starting...');
|
window.log.info('reconnectToWebSocket starting...');
|
||||||
await disconnect();
|
await server.onOffline();
|
||||||
connect();
|
await server.onOnline();
|
||||||
window.log.info('reconnectToWebSocket complete.');
|
window.log.info('reconnectToWebSocket complete.');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -1688,6 +1795,8 @@ export async function startApp(): Promise<void> {
|
||||||
window._.debounce(enqueueReconnectToWebSocket, 1000, { maxWait: 5000 })
|
window._.debounce(enqueueReconnectToWebSocket, 1000, { maxWait: 5000 })
|
||||||
);
|
);
|
||||||
|
|
||||||
|
window.Whisper.events.on('unlinkAndDisconnect', unlinkAndDisconnect);
|
||||||
|
|
||||||
function runStorageService() {
|
function runStorageService() {
|
||||||
window.Signal.Services.enableStorageService();
|
window.Signal.Services.enableStorageService();
|
||||||
|
|
||||||
|
@ -2011,8 +2120,13 @@ export async function startApp(): Promise<void> {
|
||||||
disconnectTimer = undefined;
|
disconnectTimer = undefined;
|
||||||
|
|
||||||
AttachmentDownloads.stop();
|
AttachmentDownloads.stop();
|
||||||
if (messageReceiver) {
|
if (server !== undefined) {
|
||||||
await messageReceiver.close();
|
strictAssert(
|
||||||
|
messageReceiver !== undefined,
|
||||||
|
'WebAPI should be initialized together with MessageReceiver'
|
||||||
|
);
|
||||||
|
await server.onOffline();
|
||||||
|
await messageReceiver.drain();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2056,19 +2170,6 @@ export async function startApp(): Promise<void> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
preMessageReceiverStatus = SocketStatus.CONNECTING;
|
|
||||||
|
|
||||||
if (messageReceiver) {
|
|
||||||
await messageReceiver.stopProcessing();
|
|
||||||
|
|
||||||
await window.waitForAllBatchers();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messageReceiver) {
|
|
||||||
messageReceiver.unregisterBatchers();
|
|
||||||
messageReceiver = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.textsecure.messaging = new window.textsecure.MessageSender(server);
|
window.textsecure.messaging = new window.textsecure.MessageSender(server);
|
||||||
|
|
||||||
if (connectCount === 0) {
|
if (connectCount === 0) {
|
||||||
|
@ -2115,132 +2216,20 @@ export async function startApp(): Promise<void> {
|
||||||
window.Whisper.deliveryReceiptQueue.pause();
|
window.Whisper.deliveryReceiptQueue.pause();
|
||||||
window.Whisper.Notifications.disable();
|
window.Whisper.Notifications.disable();
|
||||||
|
|
||||||
// initialize the socket and start listening for messages
|
|
||||||
window.log.info('Initializing socket and listening for messages');
|
|
||||||
const messageReceiverOptions = {
|
|
||||||
serverTrustRoot: window.getServerTrustRoot(),
|
|
||||||
};
|
|
||||||
messageReceiver = new window.textsecure.MessageReceiver(
|
|
||||||
server,
|
|
||||||
messageReceiverOptions
|
|
||||||
);
|
|
||||||
window.textsecure.messageReceiver = messageReceiver;
|
|
||||||
|
|
||||||
window.Signal.Services.initializeGroupCredentialFetcher();
|
window.Signal.Services.initializeGroupCredentialFetcher();
|
||||||
|
|
||||||
preMessageReceiverStatus = undefined;
|
strictAssert(server !== undefined, 'WebAPI not initialized');
|
||||||
|
strictAssert(
|
||||||
|
messageReceiver !== undefined,
|
||||||
|
'MessageReceiver not initialized'
|
||||||
|
);
|
||||||
|
messageReceiver.reset();
|
||||||
|
server.registerRequestHandler(messageReceiver);
|
||||||
|
|
||||||
// eslint-disable-next-line no-inner-declarations
|
// If coming here after `offline` event - connect again.
|
||||||
function queuedEventListener<Args extends Array<unknown>>(
|
await server.onOnline();
|
||||||
handler: (...args: Args) => Promise<void> | void,
|
|
||||||
track = true
|
|
||||||
): (...args: Args) => void {
|
|
||||||
return (...args: Args): void => {
|
|
||||||
eventHandlerQueue.add(async () => {
|
|
||||||
try {
|
|
||||||
await handler(...args);
|
|
||||||
} finally {
|
|
||||||
// message/sent: Message.handleDataMessage has its own queue and will
|
|
||||||
// trigger this event itself when complete.
|
|
||||||
// error: Error processing (below) also has its own queue and self-trigger.
|
|
||||||
if (track) {
|
|
||||||
window.Whisper.events.trigger('incrementProgress');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'message',
|
|
||||||
queuedEventListener(onMessageReceived, false)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'delivery',
|
|
||||||
queuedEventListener(onDeliveryReceipt)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'contact',
|
|
||||||
queuedEventListener(onContactReceived)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'contactSync',
|
|
||||||
queuedEventListener(onContactSyncComplete)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'group',
|
|
||||||
queuedEventListener(onGroupReceived)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'groupSync',
|
|
||||||
queuedEventListener(onGroupSyncComplete)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'sent',
|
|
||||||
queuedEventListener(onSentMessage, false)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'readSync',
|
|
||||||
queuedEventListener(onReadSync)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'read',
|
|
||||||
queuedEventListener(onReadReceipt)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'view',
|
|
||||||
queuedEventListener(onViewReceipt)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'verified',
|
|
||||||
queuedEventListener(onVerified)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'error',
|
|
||||||
queuedEventListener(onError, false)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'decryption-error',
|
|
||||||
queuedEventListener(onDecryptionError)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'retry-request',
|
|
||||||
queuedEventListener(onRetryRequest)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener('empty', queuedEventListener(onEmpty));
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'reconnect',
|
|
||||||
queuedEventListener(onReconnect)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'configuration',
|
|
||||||
queuedEventListener(onConfiguration)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener('typing', queuedEventListener(onTyping));
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'sticker-pack',
|
|
||||||
queuedEventListener(onStickerPack)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'viewOnceOpenSync',
|
|
||||||
queuedEventListener(onViewOnceOpenSync)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'messageRequestResponse',
|
|
||||||
queuedEventListener(onMessageRequestResponse)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'profileKeyUpdate',
|
|
||||||
queuedEventListener(onProfileKeyUpdate)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener(
|
|
||||||
'fetchLatest',
|
|
||||||
queuedEventListener(onFetchLatestSync)
|
|
||||||
);
|
|
||||||
messageReceiver.addEventListener('keys', queuedEventListener(onKeysSync));
|
|
||||||
|
|
||||||
AttachmentDownloads.start({
|
AttachmentDownloads.start({
|
||||||
getMessageReceiver: () => messageReceiver,
|
|
||||||
logger: window.log,
|
logger: window.log,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -3511,16 +3500,12 @@ export async function startApp(): Promise<void> {
|
||||||
window.Whisper.events.trigger('unauthorized');
|
window.Whisper.events.trigger('unauthorized');
|
||||||
|
|
||||||
if (messageReceiver) {
|
if (messageReceiver) {
|
||||||
|
strictAssert(server !== undefined, 'WebAPI not initialized');
|
||||||
|
server.unregisterRequestHandler(messageReceiver);
|
||||||
await messageReceiver.stopProcessing();
|
await messageReceiver.stopProcessing();
|
||||||
|
|
||||||
await window.waitForAllBatchers();
|
await window.waitForAllBatchers();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messageReceiver) {
|
|
||||||
messageReceiver.unregisterBatchers();
|
|
||||||
messageReceiver = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
onEmpty();
|
onEmpty();
|
||||||
|
|
||||||
window.log.warn(
|
window.log.warn(
|
||||||
|
|
|
@ -1689,8 +1689,8 @@ export async function createGroupV2({
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
await conversation.queueJob('storageServiceUploadJob', () => {
|
await conversation.queueJob('storageServiceUploadJob', async () => {
|
||||||
window.Signal.Services.storageServiceUploadJob();
|
await window.Signal.Services.storageServiceUploadJob();
|
||||||
});
|
});
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
|
|
@ -14,6 +14,7 @@ import {
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
|
import { longRunningTaskWrapper } from '../util/longRunningTaskWrapper';
|
||||||
import { isGroupV1 } from '../util/whatTypeOfConversation';
|
import { isGroupV1 } from '../util/whatTypeOfConversation';
|
||||||
|
import { explodePromise } from '../util/explodePromise';
|
||||||
|
|
||||||
import type { ConversationAttributesType } from '../model-types.d';
|
import type { ConversationAttributesType } from '../model-types.d';
|
||||||
import type { ConversationModel } from '../models/conversations';
|
import type { ConversationModel } from '../models/conversations';
|
||||||
|
@ -175,7 +176,7 @@ export async function joinViaLink(hash: string): Promise<void> {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Explode a promise so we know when this whole join process is complete
|
// Explode a promise so we know when this whole join process is complete
|
||||||
const { promise, resolve, reject } = explodePromise();
|
const { promise, resolve, reject } = explodePromise<void>();
|
||||||
|
|
||||||
const closeDialog = async () => {
|
const closeDialog = async () => {
|
||||||
try {
|
try {
|
||||||
|
@ -405,26 +406,3 @@ function showErrorDialog(description: string, title: string) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function explodePromise(): {
|
|
||||||
promise: Promise<void>;
|
|
||||||
resolve: () => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
} {
|
|
||||||
let resolve: () => void;
|
|
||||||
let reject: (error: Error) => void;
|
|
||||||
|
|
||||||
const promise = new Promise<void>((innerResolve, innerReject) => {
|
|
||||||
resolve = innerResolve;
|
|
||||||
reject = innerReject;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
promise,
|
|
||||||
// Typescript thinks that resolve and reject can be undefined here.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
resolve: resolve!,
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
reject: reject!,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,13 +1,12 @@
|
||||||
// Copyright 2019-2021 Signal Messenger, LLC
|
// Copyright 2019-2021 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { isFunction, isNumber, omit } from 'lodash';
|
import { isNumber, omit } from 'lodash';
|
||||||
import { v4 as getGuid } from 'uuid';
|
import { v4 as getGuid } from 'uuid';
|
||||||
|
|
||||||
import dataInterface from '../sql/Client';
|
import dataInterface from '../sql/Client';
|
||||||
import { downloadAttachment } from '../util/downloadAttachment';
|
import { downloadAttachment } from '../util/downloadAttachment';
|
||||||
import { stringFromBytes } from '../Crypto';
|
import { stringFromBytes } from '../Crypto';
|
||||||
import MessageReceiver from '../textsecure/MessageReceiver';
|
|
||||||
import {
|
import {
|
||||||
AttachmentDownloadJobType,
|
AttachmentDownloadJobType,
|
||||||
AttachmentDownloadJobTypeType,
|
AttachmentDownloadJobTypeType,
|
||||||
|
@ -42,7 +41,6 @@ const RETRY_BACKOFF: Record<number, number> = {
|
||||||
|
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
let timeout: NodeJS.Timeout | null;
|
let timeout: NodeJS.Timeout | null;
|
||||||
let getMessageReceiver: () => MessageReceiver | undefined;
|
|
||||||
let logger: LoggerType;
|
let logger: LoggerType;
|
||||||
const _activeAttachmentDownloadJobs: Record<
|
const _activeAttachmentDownloadJobs: Record<
|
||||||
string,
|
string,
|
||||||
|
@ -50,17 +48,11 @@ const _activeAttachmentDownloadJobs: Record<
|
||||||
> = {};
|
> = {};
|
||||||
|
|
||||||
type StartOptionsType = {
|
type StartOptionsType = {
|
||||||
getMessageReceiver: () => MessageReceiver | undefined;
|
|
||||||
logger: LoggerType;
|
logger: LoggerType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function start(options: StartOptionsType): Promise<void> {
|
export async function start(options: StartOptionsType): Promise<void> {
|
||||||
({ getMessageReceiver, logger } = options);
|
({ logger } = options);
|
||||||
if (!isFunction(getMessageReceiver)) {
|
|
||||||
throw new Error(
|
|
||||||
'attachment_downloads/start: getMessageReceiver must be a function'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!logger) {
|
if (!logger) {
|
||||||
throw new Error('attachment_downloads/start: logger must be provided!');
|
throw new Error('attachment_downloads/start: logger must be provided!');
|
||||||
}
|
}
|
||||||
|
@ -220,11 +212,6 @@ async function _runJob(job?: AttachmentDownloadJobType): Promise<void> {
|
||||||
const pending = true;
|
const pending = true;
|
||||||
await setAttachmentDownloadJobPending(id, pending);
|
await setAttachmentDownloadJobPending(id, pending);
|
||||||
|
|
||||||
const messageReceiver = getMessageReceiver();
|
|
||||||
if (!messageReceiver) {
|
|
||||||
throw new Error('_runJob: messageReceiver not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const downloaded = await downloadAttachment(attachment);
|
const downloaded = await downloadAttachment(attachment);
|
||||||
|
|
||||||
if (!downloaded) {
|
if (!downloaded) {
|
||||||
|
|
|
@ -6,17 +6,9 @@
|
||||||
import { Collection, Model } from 'backbone';
|
import { Collection, Model } from 'backbone';
|
||||||
import { MessageModel } from '../models/messages';
|
import { MessageModel } from '../models/messages';
|
||||||
import { isOutgoing } from '../state/selectors/message';
|
import { isOutgoing } from '../state/selectors/message';
|
||||||
|
import { ReactionAttributesType } from '../model-types.d';
|
||||||
|
|
||||||
type ReactionsAttributesType = {
|
export class ReactionModel extends Model<ReactionAttributesType> {}
|
||||||
emoji: string;
|
|
||||||
remove: boolean;
|
|
||||||
targetAuthorUuid: string;
|
|
||||||
targetTimestamp: number;
|
|
||||||
timestamp: number;
|
|
||||||
fromId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ReactionModel extends Model<ReactionsAttributesType> {}
|
|
||||||
|
|
||||||
let singleton: Reactions | undefined;
|
let singleton: Reactions | undefined;
|
||||||
|
|
||||||
|
@ -63,7 +55,7 @@ export class Reactions extends Collection {
|
||||||
|
|
||||||
async onReaction(
|
async onReaction(
|
||||||
reaction: ReactionModel
|
reaction: ReactionModel
|
||||||
): Promise<ReactionModel | undefined> {
|
): Promise<ReactionAttributesType | undefined> {
|
||||||
try {
|
try {
|
||||||
// The conversation the target message was in; we have to find it in the database
|
// The conversation the target message was in; we have to find it in the database
|
||||||
// to to figure that out.
|
// to to figure that out.
|
||||||
|
|
|
@ -16,6 +16,7 @@ import { AttachmentType } from '../types/Attachment';
|
||||||
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
|
import { CallMode, CallHistoryDetailsType } from '../types/Calling';
|
||||||
import * as Stickers from '../types/Stickers';
|
import * as Stickers from '../types/Stickers';
|
||||||
import { GroupV2InfoType } from '../textsecure/SendMessage';
|
import { GroupV2InfoType } from '../textsecure/SendMessage';
|
||||||
|
import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
|
||||||
import { CallbackResultType } from '../textsecure/Types.d';
|
import { CallbackResultType } from '../textsecure/Types.d';
|
||||||
import { ConversationType } from '../state/ducks/conversations';
|
import { ConversationType } from '../state/ducks/conversations';
|
||||||
import {
|
import {
|
||||||
|
@ -1058,7 +1059,7 @@ export class ConversationModel extends window.Backbone
|
||||||
this.setRegistered();
|
this.setRegistered();
|
||||||
|
|
||||||
// If we couldn't apply universal timer before - try it again.
|
// If we couldn't apply universal timer before - try it again.
|
||||||
this.queueJob('maybeSetPendingUniversalTimer', () =>
|
this.queueJob('maybeSetPendingUniversalTimer', async () =>
|
||||||
this.maybeSetPendingUniversalTimer()
|
this.maybeSetPendingUniversalTimer()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2893,13 +2894,10 @@ export class ConversationModel extends window.Backbone
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
queueJob(
|
queueJob<T>(name: string, callback: () => Promise<T>): Promise<T> {
|
||||||
name: string,
|
|
||||||
callback: () => unknown | Promise<unknown>
|
|
||||||
): Promise<WhatIsThis> {
|
|
||||||
this.jobQueue = this.jobQueue || new window.PQueue({ concurrency: 1 });
|
this.jobQueue = this.jobQueue || new window.PQueue({ concurrency: 1 });
|
||||||
|
|
||||||
const taskWithTimeout = window.textsecure.createTaskWithTimeout(
|
const taskWithTimeout = createTaskWithTimeout(
|
||||||
callback,
|
callback,
|
||||||
`conversation ${this.idForLogging()}`
|
`conversation ${this.idForLogging()}`
|
||||||
);
|
);
|
||||||
|
@ -3231,7 +3229,7 @@ export class ConversationModel extends window.Backbone
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const destination = this.getSendTarget()!;
|
const destination = this.getSendTarget()!;
|
||||||
|
|
||||||
return this.queueJob('sendDeleteForEveryone', async () => {
|
await this.queueJob('sendDeleteForEveryone', async () => {
|
||||||
window.log.info(
|
window.log.info(
|
||||||
'Sending deleteForEveryone to conversation',
|
'Sending deleteForEveryone to conversation',
|
||||||
this.idForLogging(),
|
this.idForLogging(),
|
||||||
|
@ -3782,7 +3780,7 @@ export class ConversationModel extends window.Backbone
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.queueJob('maybeSetPendingUniversalTimer', () =>
|
this.queueJob('maybeSetPendingUniversalTimer', async () =>
|
||||||
this.maybeSetPendingUniversalTimer()
|
this.maybeSetPendingUniversalTimer()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -4804,7 +4802,7 @@ export class ConversationModel extends window.Backbone
|
||||||
);
|
);
|
||||||
this.set({ needsStorageServiceSync: true });
|
this.set({ needsStorageServiceSync: true });
|
||||||
|
|
||||||
this.queueJob('captureChange', () => {
|
this.queueJob('captureChange', async () => {
|
||||||
Services.storageServiceUploadJob();
|
Services.storageServiceUploadJob();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
47
ts/test-both/TaskWithTimeout_test.ts
Normal file
47
ts/test-both/TaskWithTimeout_test.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
// Copyright 2017-2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { sleep } from '../util/sleep';
|
||||||
|
import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
|
||||||
|
|
||||||
|
describe('createTaskWithTimeout', () => {
|
||||||
|
it('resolves when promise resolves', async () => {
|
||||||
|
const task = () => Promise.resolve('hi!');
|
||||||
|
const taskWithTimeout = createTaskWithTimeout(task, 'test');
|
||||||
|
|
||||||
|
const result = await taskWithTimeout();
|
||||||
|
assert.strictEqual(result, 'hi!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flows error from promise back', async () => {
|
||||||
|
const error = new Error('original');
|
||||||
|
const task = () => Promise.reject(error);
|
||||||
|
const taskWithTimeout = createTaskWithTimeout(task, 'test');
|
||||||
|
|
||||||
|
await assert.isRejected(taskWithTimeout(), 'original');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects if promise takes too long (this one logs error to console)', async () => {
|
||||||
|
const task = async () => {
|
||||||
|
await sleep(3000);
|
||||||
|
};
|
||||||
|
const taskWithTimeout = createTaskWithTimeout(task, 'test', {
|
||||||
|
timeout: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.isRejected(taskWithTimeout());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects if task throws (and does not log about taking too long)', async () => {
|
||||||
|
const error = new Error('Task is throwing!');
|
||||||
|
const task = () => {
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
|
const taskWithTimeout = createTaskWithTimeout(task, 'test', {
|
||||||
|
timeout: 10,
|
||||||
|
});
|
||||||
|
await assert.isRejected(taskWithTimeout(), 'Task is throwing!');
|
||||||
|
});
|
||||||
|
});
|
46
ts/test-both/util/AbortableProcess_test.ts
Normal file
46
ts/test-both/util/AbortableProcess_test.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
|
import { AbortableProcess } from '../../util/AbortableProcess';
|
||||||
|
|
||||||
|
describe('AbortableProcess', () => {
|
||||||
|
it('resolves the result normally', async () => {
|
||||||
|
const process = new AbortableProcess(
|
||||||
|
'process',
|
||||||
|
{ abort: noop },
|
||||||
|
Promise.resolve(42)
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.strictEqual(await process.getResult(), 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects normally', async () => {
|
||||||
|
const process = new AbortableProcess(
|
||||||
|
'process',
|
||||||
|
{ abort: noop },
|
||||||
|
Promise.reject(new Error('rejected'))
|
||||||
|
);
|
||||||
|
|
||||||
|
await assert.isRejected(process.getResult(), 'rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on abort', async () => {
|
||||||
|
let calledAbort = false;
|
||||||
|
const process = new AbortableProcess(
|
||||||
|
'A',
|
||||||
|
{
|
||||||
|
abort() {
|
||||||
|
calledAbort = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new Promise(noop)
|
||||||
|
);
|
||||||
|
|
||||||
|
process.abort();
|
||||||
|
await assert.isRejected(process.getResult(), 'Process "A" was aborted');
|
||||||
|
assert.isTrue(calledAbort);
|
||||||
|
});
|
||||||
|
});
|
26
ts/test-both/util/explodePromise_test.ts
Normal file
26
ts/test-both/util/explodePromise_test.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
|
||||||
|
import { assert } from 'chai';
|
||||||
|
|
||||||
|
import { explodePromise } from '../../util/explodePromise';
|
||||||
|
|
||||||
|
describe('explodePromise', () => {
|
||||||
|
it('resolves the promise', async () => {
|
||||||
|
const { promise, resolve } = explodePromise<number>();
|
||||||
|
|
||||||
|
resolve(42);
|
||||||
|
|
||||||
|
assert.strictEqual(await promise, 42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects the promise', async () => {
|
||||||
|
const { promise, reject } = explodePromise<number>();
|
||||||
|
|
||||||
|
reject(new Error('rejected'));
|
||||||
|
|
||||||
|
await assert.isRejected(promise, 'rejected');
|
||||||
|
});
|
||||||
|
});
|
|
@ -6,12 +6,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import EventEmitter from 'events';
|
|
||||||
import { connection as WebSocket } from 'websocket';
|
|
||||||
|
|
||||||
import MessageReceiver from '../textsecure/MessageReceiver';
|
import MessageReceiver from '../textsecure/MessageReceiver';
|
||||||
import { DecryptionErrorEvent } from '../textsecure/messageReceiverEvents';
|
import { IncomingWebSocketRequest } from '../textsecure/WebsocketResources';
|
||||||
import { WebAPIType } from '../textsecure/WebAPI';
|
import { WebAPIType } from '../textsecure/WebAPI';
|
||||||
|
import { DecryptionErrorEvent } from '../textsecure/messageReceiverEvents';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
import * as Crypto from '../Crypto';
|
import * as Crypto from '../Crypto';
|
||||||
|
|
||||||
|
@ -19,23 +18,16 @@ import * as Crypto from '../Crypto';
|
||||||
const FIXMEU8 = Uint8Array;
|
const FIXMEU8 = Uint8Array;
|
||||||
|
|
||||||
describe('MessageReceiver', () => {
|
describe('MessageReceiver', () => {
|
||||||
class FakeSocket extends EventEmitter {
|
|
||||||
public sendBytes(_: Uint8Array) {}
|
|
||||||
|
|
||||||
public close() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const number = '+19999999999';
|
const number = '+19999999999';
|
||||||
const uuid = 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee';
|
const uuid = 'aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee';
|
||||||
const deviceId = 1;
|
const deviceId = 1;
|
||||||
|
|
||||||
describe('connecting', () => {
|
describe('connecting', () => {
|
||||||
it('generates decryption-error event when it cannot decrypt', done => {
|
it('generates decryption-error event when it cannot decrypt', done => {
|
||||||
const socket = new FakeSocket();
|
const messageReceiver = new MessageReceiver({
|
||||||
|
server: {} as WebAPIType,
|
||||||
const messageReceiver = new MessageReceiver({} as WebAPIType, {
|
storage: window.storage,
|
||||||
serverTrustRoot: 'AAAAAAAA',
|
serverTrustRoot: 'AAAAAAAA',
|
||||||
socket: socket as WebSocket,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = Proto.Envelope.encode({
|
const body = Proto.Envelope.encode({
|
||||||
|
@ -47,15 +39,18 @@ describe('MessageReceiver', () => {
|
||||||
content: new FIXMEU8(Crypto.getRandomBytes(200)),
|
content: new FIXMEU8(Crypto.getRandomBytes(200)),
|
||||||
}).finish();
|
}).finish();
|
||||||
|
|
||||||
const message = Proto.WebSocketMessage.encode({
|
messageReceiver.handleRequest(
|
||||||
type: Proto.WebSocketMessage.Type.REQUEST,
|
new IncomingWebSocketRequest(
|
||||||
request: { id: 1, verb: 'PUT', path: '/api/v1/message', body },
|
{
|
||||||
}).finish();
|
id: 1,
|
||||||
|
verb: 'PUT',
|
||||||
socket.emit('message', {
|
path: '/api/v1/message',
|
||||||
type: 'binary',
|
body,
|
||||||
binaryData: message,
|
headers: [],
|
||||||
});
|
},
|
||||||
|
(_: Buffer): void => {}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
messageReceiver.addEventListener(
|
messageReceiver.addEventListener(
|
||||||
'decryption-error',
|
'decryption-error',
|
||||||
|
|
|
@ -85,7 +85,7 @@ describe('WebSocket-Resource', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends requests and receives responses', done => {
|
it('sends requests and receives responses', async () => {
|
||||||
// mock socket and request handler
|
// mock socket and request handler
|
||||||
let requestId: number | Long | undefined;
|
let requestId: number | Long | undefined;
|
||||||
const socket = new FakeSocket();
|
const socket = new FakeSocket();
|
||||||
|
@ -101,16 +101,10 @@ describe('WebSocket-Resource', () => {
|
||||||
|
|
||||||
// actual test
|
// actual test
|
||||||
const resource = new WebSocketResource(socket as WebSocket);
|
const resource = new WebSocketResource(socket as WebSocket);
|
||||||
resource.sendRequest({
|
const promise = resource.sendRequest({
|
||||||
verb: 'PUT',
|
verb: 'PUT',
|
||||||
path: '/some/path',
|
path: '/some/path',
|
||||||
body: new Uint8Array([1, 2, 3]),
|
body: new Uint8Array([1, 2, 3]),
|
||||||
error: done,
|
|
||||||
success(message: string, status: number) {
|
|
||||||
assert.strictEqual(message, 'OK');
|
|
||||||
assert.strictEqual(status, 200);
|
|
||||||
done();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// mock socket response
|
// mock socket response
|
||||||
|
@ -121,6 +115,10 @@ describe('WebSocket-Resource', () => {
|
||||||
response: { id: requestId, message: 'OK', status: 200 },
|
response: { id: requestId, message: 'OK', status: 200 },
|
||||||
}).finish(),
|
}).finish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { status, message } = await promise;
|
||||||
|
assert.strictEqual(message, 'OK');
|
||||||
|
assert.strictEqual(status, 200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
9
ts/textsecure.d.ts
vendored
9
ts/textsecure.d.ts
vendored
|
@ -4,7 +4,6 @@
|
||||||
import { UnidentifiedSenderMessageContent } from '@signalapp/signal-client';
|
import { UnidentifiedSenderMessageContent } from '@signalapp/signal-client';
|
||||||
|
|
||||||
import Crypto from './textsecure/Crypto';
|
import Crypto from './textsecure/Crypto';
|
||||||
import MessageReceiver from './textsecure/MessageReceiver';
|
|
||||||
import MessageSender from './textsecure/SendMessage';
|
import MessageSender from './textsecure/SendMessage';
|
||||||
import SyncRequest from './textsecure/SyncRequest';
|
import SyncRequest from './textsecure/SyncRequest';
|
||||||
import EventTarget from './textsecure/EventTarget';
|
import EventTarget from './textsecure/EventTarget';
|
||||||
|
@ -38,20 +37,14 @@ export type UnprocessedType = {
|
||||||
export { StorageServiceCallOptionsType, StorageServiceCredentials };
|
export { StorageServiceCallOptionsType, StorageServiceCredentials };
|
||||||
|
|
||||||
export type TextSecureType = {
|
export type TextSecureType = {
|
||||||
createTaskWithTimeout: (
|
|
||||||
task: () => Promise<any> | any,
|
|
||||||
id?: string,
|
|
||||||
options?: { timeout?: number }
|
|
||||||
) => () => Promise<any>;
|
|
||||||
crypto: typeof Crypto;
|
crypto: typeof Crypto;
|
||||||
storage: Storage;
|
storage: Storage;
|
||||||
messageReceiver: MessageReceiver;
|
server: WebAPIType;
|
||||||
messageSender: MessageSender;
|
messageSender: MessageSender;
|
||||||
messaging: SendMessage;
|
messaging: SendMessage;
|
||||||
utils: typeof utils;
|
utils: typeof utils;
|
||||||
|
|
||||||
EventTarget: typeof EventTarget;
|
EventTarget: typeof EventTarget;
|
||||||
MessageReceiver: typeof MessageReceiver;
|
|
||||||
AccountManager: WhatIsThis;
|
AccountManager: WhatIsThis;
|
||||||
MessageSender: typeof MessageSender;
|
MessageSender: typeof MessageSender;
|
||||||
SyncRequest: typeof SyncRequest;
|
SyncRequest: typeof SyncRequest;
|
||||||
|
|
|
@ -13,9 +13,8 @@ import { WebAPIType } from './WebAPI';
|
||||||
import { KeyPairType, CompatSignedPreKeyType } from './Types.d';
|
import { KeyPairType, CompatSignedPreKeyType } from './Types.d';
|
||||||
import utils from './Helpers';
|
import utils from './Helpers';
|
||||||
import ProvisioningCipher from './ProvisioningCipher';
|
import ProvisioningCipher from './ProvisioningCipher';
|
||||||
import WebSocketResource, {
|
import { IncomingWebSocketRequest } from './WebsocketResources';
|
||||||
IncomingWebSocketRequest,
|
import createTaskWithTimeout from './TaskWithTimeout';
|
||||||
} from './WebsocketResources';
|
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import {
|
import {
|
||||||
deriveAccessKey,
|
deriveAccessKey,
|
||||||
|
@ -192,31 +191,22 @@ export default class AccountManager extends EventTarget {
|
||||||
SIGNED_KEY_GEN_BATCH_SIZE,
|
SIGNED_KEY_GEN_BATCH_SIZE,
|
||||||
progressCallback
|
progressCallback
|
||||||
);
|
);
|
||||||
const confirmKeys = this.confirmKeys.bind(this);
|
|
||||||
const registrationDone = this.registrationDone.bind(this);
|
|
||||||
const registerKeys = this.server.registerKeys.bind(this.server);
|
|
||||||
const getSocket = this.server.getProvisioningSocket.bind(this.server);
|
|
||||||
const queueTask = this.queueTask.bind(this);
|
|
||||||
const provisioningCipher = new ProvisioningCipher();
|
const provisioningCipher = new ProvisioningCipher();
|
||||||
let gotProvisionEnvelope = false;
|
|
||||||
const pubKey = await provisioningCipher.getPublicKey();
|
const pubKey = await provisioningCipher.getPublicKey();
|
||||||
|
|
||||||
const socket = await getSocket();
|
let envelopeCallbacks:
|
||||||
|
| {
|
||||||
window.log.info('provisioning socket open');
|
resolve(data: Proto.ProvisionEnvelope): void;
|
||||||
|
reject(error: Error): void;
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
socket.on('close', (code, reason) => {
|
|
||||||
window.log.info(
|
|
||||||
`provisioning socket closed. Code: ${code} Reason: ${reason}`
|
|
||||||
);
|
|
||||||
if (!gotProvisionEnvelope) {
|
|
||||||
reject(new Error('websocket closed'));
|
|
||||||
}
|
}
|
||||||
});
|
| undefined;
|
||||||
|
const envelopePromise = new Promise<Proto.ProvisionEnvelope>(
|
||||||
|
(resolve, reject) => {
|
||||||
|
envelopeCallbacks = { resolve, reject };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const wsr = new WebSocketResource(socket, {
|
const wsr = await this.server.getProvisioningResource({
|
||||||
keepalive: { path: '/v1/keepalive/provisioning' },
|
|
||||||
handleRequest(request: IncomingWebSocketRequest) {
|
handleRequest(request: IncomingWebSocketRequest) {
|
||||||
if (
|
if (
|
||||||
request.path === '/v1/address' &&
|
request.path === '/v1/address' &&
|
||||||
|
@ -243,19 +233,31 @@ export default class AccountManager extends EventTarget {
|
||||||
) {
|
) {
|
||||||
const envelope = Proto.ProvisionEnvelope.decode(request.body);
|
const envelope = Proto.ProvisionEnvelope.decode(request.body);
|
||||||
request.respond(200, 'OK');
|
request.respond(200, 'OK');
|
||||||
gotProvisionEnvelope = true;
|
|
||||||
wsr.close();
|
wsr.close();
|
||||||
resolve(
|
envelopeCallbacks?.resolve(envelope);
|
||||||
provisioningCipher
|
} else {
|
||||||
.decrypt(envelope)
|
window.log.error('Unknown websocket message', request.path);
|
||||||
.then(async provisionMessage =>
|
}
|
||||||
queueTask(async () =>
|
},
|
||||||
confirmNumber(provisionMessage.number).then(
|
});
|
||||||
async deviceName => {
|
|
||||||
if (
|
window.log.info('provisioning socket open');
|
||||||
typeof deviceName !== 'string' ||
|
|
||||||
deviceName.length === 0
|
wsr.addEventListener('close', ({ code, reason }) => {
|
||||||
) {
|
window.log.info(
|
||||||
|
`provisioning socket closed. Code: ${code} Reason: ${reason}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: if we have resolved the envelope already - this has no effect
|
||||||
|
envelopeCallbacks?.reject(new Error('websocket closed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const envelope = await envelopePromise;
|
||||||
|
const provisionMessage = await provisioningCipher.decrypt(envelope);
|
||||||
|
|
||||||
|
await this.queueTask(async () => {
|
||||||
|
const deviceName = await confirmNumber(provisionMessage.number);
|
||||||
|
if (typeof deviceName !== 'string' || deviceName.length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'AccountManager.registerSecondDevice: Invalid device name'
|
'AccountManager.registerSecondDevice: Invalid device name'
|
||||||
);
|
);
|
||||||
|
@ -270,7 +272,7 @@ export default class AccountManager extends EventTarget {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return createAccount(
|
await createAccount(
|
||||||
provisionMessage.number,
|
provisionMessage.number,
|
||||||
provisionMessage.provisioningCode,
|
provisionMessage.provisioningCode,
|
||||||
provisionMessage.identityKeyPair,
|
provisionMessage.identityKeyPair,
|
||||||
|
@ -279,25 +281,12 @@ export default class AccountManager extends EventTarget {
|
||||||
provisionMessage.userAgent,
|
provisionMessage.userAgent,
|
||||||
provisionMessage.readReceipts,
|
provisionMessage.readReceipts,
|
||||||
{ uuid: provisionMessage.uuid }
|
{ uuid: provisionMessage.uuid }
|
||||||
)
|
|
||||||
.then(clearSessionsAndPreKeys)
|
|
||||||
.then(generateKeys)
|
|
||||||
.then(async (keys: GeneratedKeysType) =>
|
|
||||||
registerKeys(keys).then(async () =>
|
|
||||||
confirmKeys(keys)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(registrationDone);
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
} else {
|
await clearSessionsAndPreKeys();
|
||||||
window.log.error('Unknown websocket message', request.path);
|
const keys = await generateKeys();
|
||||||
}
|
await this.server.registerKeys(keys);
|
||||||
},
|
await this.confirmKeys(keys);
|
||||||
});
|
await this.registrationDone();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -416,7 +405,7 @@ export default class AccountManager extends EventTarget {
|
||||||
|
|
||||||
async queueTask(task: () => Promise<any>) {
|
async queueTask(task: () => Promise<any>) {
|
||||||
this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 });
|
this.pendingQueue = this.pendingQueue || new PQueue({ concurrency: 1 });
|
||||||
const taskWithTimeout = window.textsecure.createTaskWithTimeout(task);
|
const taskWithTimeout = createTaskWithTimeout(task, 'AccountManager task');
|
||||||
|
|
||||||
return this.pendingQueue.add(taskWithTimeout);
|
return this.pendingQueue.add(taskWithTimeout);
|
||||||
}
|
}
|
||||||
|
@ -633,6 +622,11 @@ export default class AccountManager extends EventTarget {
|
||||||
);
|
);
|
||||||
await window.textsecure.storage.put('regionCode', regionCode);
|
await window.textsecure.storage.put('regionCode', regionCode);
|
||||||
await window.textsecure.storage.protocol.hydrateCaches();
|
await window.textsecure.storage.protocol.hydrateCaches();
|
||||||
|
|
||||||
|
// We are finally ready to reconnect
|
||||||
|
window.textsecure.storage.user.emitCredentialsChanged(
|
||||||
|
'AccountManager.createAccount'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearSessionsAndPreKeys() {
|
async clearSessionsAndPreKeys() {
|
||||||
|
|
|
@ -13,6 +13,40 @@ function appendStack(newError: Error, originalError: Error) {
|
||||||
newError.stack += `\nOriginal stack:\n${originalError.stack}`;
|
newError.stack += `\nOriginal stack:\n${originalError.stack}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type HTTPErrorHeadersType = {
|
||||||
|
[name: string]: string | ReadonlyArray<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class HTTPError extends Error {
|
||||||
|
public readonly name = 'HTTPError';
|
||||||
|
|
||||||
|
public readonly code: number;
|
||||||
|
|
||||||
|
public readonly responseHeaders: HTTPErrorHeadersType;
|
||||||
|
|
||||||
|
public readonly response: unknown;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
options: {
|
||||||
|
code: number;
|
||||||
|
headers: HTTPErrorHeadersType;
|
||||||
|
response?: unknown;
|
||||||
|
stack?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
super(`${message}; code: ${options.code}`);
|
||||||
|
|
||||||
|
const { code: providedCode, headers, response, stack } = options;
|
||||||
|
|
||||||
|
this.code = providedCode > 999 || providedCode < 100 ? -1 : providedCode;
|
||||||
|
this.responseHeaders = headers;
|
||||||
|
|
||||||
|
this.stack += `\nOriginal stack:\n${stack}`;
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ReplayableError extends Error {
|
export class ReplayableError extends Error {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
|
|
@ -6,10 +6,9 @@
|
||||||
/* eslint-disable camelcase */
|
/* eslint-disable camelcase */
|
||||||
/* eslint-disable no-restricted-syntax */
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
|
||||||
import { isNumber, map, omit } from 'lodash';
|
import { isNumber, map } from 'lodash';
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { v4 as getGuid } from 'uuid';
|
import { v4 as getGuid } from 'uuid';
|
||||||
import { connection as WebSocket } from 'websocket';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DecryptionErrorMessage,
|
DecryptionErrorMessage,
|
||||||
|
@ -38,42 +37,37 @@ import {
|
||||||
SignedPreKeys,
|
SignedPreKeys,
|
||||||
} from '../LibSignalStores';
|
} from '../LibSignalStores';
|
||||||
import { verifySignature } from '../Curve';
|
import { verifySignature } from '../Curve';
|
||||||
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
|
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { BatcherType, createBatcher } from '../util/batcher';
|
import { BatcherType, createBatcher } from '../util/batcher';
|
||||||
import { dropNull } from '../util/dropNull';
|
import { dropNull } from '../util/dropNull';
|
||||||
import { normalizeUuid } from '../util/normalizeUuid';
|
import { normalizeUuid } from '../util/normalizeUuid';
|
||||||
import { normalizeNumber } from '../util/normalizeNumber';
|
import { normalizeNumber } from '../util/normalizeNumber';
|
||||||
import { sleep } from '../util/sleep';
|
|
||||||
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
import { parseIntOrThrow } from '../util/parseIntOrThrow';
|
||||||
import { Zone } from '../util/Zone';
|
import { Zone } from '../util/Zone';
|
||||||
|
import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto';
|
||||||
|
import { DownloadedAttachmentType } from '../types/Attachment';
|
||||||
|
import * as Errors from '../types/errors';
|
||||||
|
|
||||||
|
import { SignalService as Proto } from '../protobuf';
|
||||||
|
import { UnprocessedType } from '../textsecure.d';
|
||||||
|
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
|
||||||
|
|
||||||
|
import createTaskWithTimeout from './TaskWithTimeout';
|
||||||
import { processAttachment, processDataMessage } from './processDataMessage';
|
import { processAttachment, processDataMessage } from './processDataMessage';
|
||||||
import { processSyncMessage } from './processSyncMessage';
|
import { processSyncMessage } from './processSyncMessage';
|
||||||
import EventTarget, { EventHandler } from './EventTarget';
|
import EventTarget, { EventHandler } from './EventTarget';
|
||||||
import { WebAPIType } from './WebAPI';
|
import { downloadAttachment } from './downloadAttachment';
|
||||||
import WebSocketResource, {
|
import { IncomingWebSocketRequest } from './WebsocketResources';
|
||||||
IncomingWebSocketRequest,
|
|
||||||
CloseEvent,
|
|
||||||
} from './WebsocketResources';
|
|
||||||
import { ConnectTimeoutError } from './Errors';
|
|
||||||
import * as Bytes from '../Bytes';
|
|
||||||
import Crypto from './Crypto';
|
|
||||||
import { deriveMasterKeyFromGroupV1, typedArrayToArrayBuffer } from '../Crypto';
|
|
||||||
import { ContactBuffer, GroupBuffer } from './ContactsParser';
|
import { ContactBuffer, GroupBuffer } from './ContactsParser';
|
||||||
import { DownloadedAttachmentType } from '../types/Attachment';
|
import type { WebAPIType } from './WebAPI';
|
||||||
import * as Errors from '../types/errors';
|
import type { Storage } from './Storage';
|
||||||
import * as MIME from '../types/MIME';
|
import * as Bytes from '../Bytes';
|
||||||
import { SocketStatus } from '../types/SocketStatus';
|
|
||||||
|
|
||||||
import { SignalService as Proto } from '../protobuf';
|
|
||||||
|
|
||||||
import { UnprocessedType } from '../textsecure.d';
|
|
||||||
import {
|
import {
|
||||||
ProcessedAttachment,
|
|
||||||
ProcessedDataMessage,
|
ProcessedDataMessage,
|
||||||
ProcessedSyncMessage,
|
ProcessedSyncMessage,
|
||||||
ProcessedSent,
|
ProcessedSent,
|
||||||
ProcessedEnvelope,
|
ProcessedEnvelope,
|
||||||
|
IRequestHandler,
|
||||||
} from './Types.d';
|
} from './Types.d';
|
||||||
import {
|
import {
|
||||||
ReconnectEvent,
|
ReconnectEvent,
|
||||||
|
@ -103,8 +97,6 @@ import {
|
||||||
GroupSyncEvent,
|
GroupSyncEvent,
|
||||||
} from './messageReceiverEvents';
|
} from './messageReceiverEvents';
|
||||||
|
|
||||||
import { deriveGroupFields, MASTER_KEY_LENGTH } from '../groups';
|
|
||||||
|
|
||||||
// TODO: remove once we move away from ArrayBuffers
|
// TODO: remove once we move away from ArrayBuffers
|
||||||
const FIXMEU8 = Uint8Array;
|
const FIXMEU8 = Uint8Array;
|
||||||
|
|
||||||
|
@ -150,12 +142,18 @@ enum TaskType {
|
||||||
Decrypted = 'Decrypted',
|
Decrypted = 'Decrypted',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class MessageReceiver extends EventTarget {
|
export type MessageReceiverOptions = {
|
||||||
private _onClose?: (code: number, reason: string) => Promise<void>;
|
server: WebAPIType;
|
||||||
|
storage: Storage;
|
||||||
|
serverTrustRoot: string;
|
||||||
|
};
|
||||||
|
|
||||||
private _onWSRClose?: (event: CloseEvent) => void;
|
export default class MessageReceiver
|
||||||
|
extends EventTarget
|
||||||
|
implements IRequestHandler {
|
||||||
|
private server: WebAPIType;
|
||||||
|
|
||||||
private _onError?: (error: Error) => Promise<void>;
|
private storage: Storage;
|
||||||
|
|
||||||
private appQueue: PQueue;
|
private appQueue: PQueue;
|
||||||
|
|
||||||
|
@ -163,22 +161,14 @@ export default class MessageReceiver extends EventTarget {
|
||||||
|
|
||||||
private cacheRemoveBatcher: BatcherType<string>;
|
private cacheRemoveBatcher: BatcherType<string>;
|
||||||
|
|
||||||
private calledClose?: boolean;
|
|
||||||
|
|
||||||
private count: number;
|
private count: number;
|
||||||
|
|
||||||
private processedCount: number;
|
private processedCount: number;
|
||||||
|
|
||||||
private deviceId?: number;
|
|
||||||
|
|
||||||
private hasConnected = false;
|
|
||||||
|
|
||||||
private incomingQueue: PQueue;
|
private incomingQueue: PQueue;
|
||||||
|
|
||||||
private isEmptied?: boolean;
|
private isEmptied?: boolean;
|
||||||
|
|
||||||
private number_id?: string;
|
|
||||||
|
|
||||||
private encryptedQueue: PQueue;
|
private encryptedQueue: PQueue;
|
||||||
|
|
||||||
private decryptedQueue: PQueue;
|
private decryptedQueue: PQueue;
|
||||||
|
@ -187,38 +177,21 @@ export default class MessageReceiver extends EventTarget {
|
||||||
|
|
||||||
private serverTrustRoot: Uint8Array;
|
private serverTrustRoot: Uint8Array;
|
||||||
|
|
||||||
private socket?: WebSocket;
|
|
||||||
|
|
||||||
private socketStatus = SocketStatus.CLOSED;
|
|
||||||
|
|
||||||
private stoppingProcessing?: boolean;
|
private stoppingProcessing?: boolean;
|
||||||
|
|
||||||
private uuid_id?: string;
|
constructor({ server, storage, serverTrustRoot }: MessageReceiverOptions) {
|
||||||
|
|
||||||
private wsr?: WebSocketResource;
|
|
||||||
|
|
||||||
private readonly reconnectBackOff = new BackOff(FIBONACCI_TIMEOUTS);
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
public readonly server: WebAPIType,
|
|
||||||
options: {
|
|
||||||
serverTrustRoot: string;
|
|
||||||
socket?: WebSocket;
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.server = server;
|
||||||
|
this.storage = storage;
|
||||||
|
|
||||||
this.count = 0;
|
this.count = 0;
|
||||||
this.processedCount = 0;
|
this.processedCount = 0;
|
||||||
|
|
||||||
if (!options.serverTrustRoot) {
|
if (!serverTrustRoot) {
|
||||||
throw new Error('Server trust root is required!');
|
throw new Error('Server trust root is required!');
|
||||||
}
|
}
|
||||||
this.serverTrustRoot = Bytes.fromBase64(options.serverTrustRoot);
|
this.serverTrustRoot = Bytes.fromBase64(serverTrustRoot);
|
||||||
|
|
||||||
this.number_id = window.textsecure.storage.user.getNumber();
|
|
||||||
this.uuid_id = window.textsecure.storage.user.getUuid();
|
|
||||||
this.deviceId = window.textsecure.storage.user.getDeviceId();
|
|
||||||
|
|
||||||
this.incomingQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 });
|
this.incomingQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 });
|
||||||
this.appQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 });
|
this.appQueue = new PQueue({ concurrency: 1, timeout: 1000 * 60 * 2 });
|
||||||
|
@ -249,174 +222,124 @@ export default class MessageReceiver extends EventTarget {
|
||||||
maxSize: 30,
|
maxSize: 30,
|
||||||
processBatch: this.cacheRemoveBatch.bind(this),
|
processBatch: this.cacheRemoveBatch.bind(this),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.connect(options.socket);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async stopProcessing(): Promise<void> {
|
|
||||||
window.log.info('MessageReceiver: stopProcessing requested');
|
|
||||||
this.stoppingProcessing = true;
|
|
||||||
return this.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
public unregisterBatchers(): void {
|
|
||||||
window.log.info('MessageReceiver: unregister batchers');
|
|
||||||
this.decryptAndCacheBatcher.unregister();
|
|
||||||
this.cacheRemoveBatcher.unregister();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getProcessedCount(): number {
|
public getProcessedCount(): number {
|
||||||
return this.processedCount;
|
return this.processedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connect(socket?: WebSocket): Promise<void> {
|
public handleRequest(request: IncomingWebSocketRequest): void {
|
||||||
if (this.calledClose) {
|
// We do the message decryption here, instead of in the ordered pending queue,
|
||||||
|
// to avoid exposing the time it took us to process messages through the time-to-ack.
|
||||||
|
window.log.info('MessageReceiver: got request', request.verb, request.path);
|
||||||
|
if (request.path !== '/api/v1/message') {
|
||||||
|
request.respond(200, 'OK');
|
||||||
|
|
||||||
|
if (request.verb === 'PUT' && request.path === '/api/v1/queue/empty') {
|
||||||
|
this.incomingQueue.add(() => {
|
||||||
|
this.onEmpty();
|
||||||
|
});
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const job = async () => {
|
||||||
|
const headers = request.headers || [];
|
||||||
|
|
||||||
|
if (!request.body) {
|
||||||
|
throw new Error(
|
||||||
|
'MessageReceiver.handleRequest: request.body was falsey!'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintext = request.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = Proto.Envelope.decode(plaintext);
|
||||||
|
const serverTimestamp = normalizeNumber(decoded.serverTimestamp);
|
||||||
|
|
||||||
|
const envelope: ProcessedEnvelope = {
|
||||||
|
// Make non-private envelope IDs dashless so they don't get redacted
|
||||||
|
// from logs
|
||||||
|
id: getGuid().replace(/-/g, ''),
|
||||||
|
receivedAtCounter: window.Signal.Util.incrementMessageCounter(),
|
||||||
|
receivedAtDate: Date.now(),
|
||||||
|
// Calculate the message age (time on server).
|
||||||
|
messageAgeSec: this.calculateMessageAge(headers, serverTimestamp),
|
||||||
|
|
||||||
|
// Proto.Envelope fields
|
||||||
|
type: decoded.type,
|
||||||
|
source: decoded.source,
|
||||||
|
sourceUuid: decoded.sourceUuid
|
||||||
|
? normalizeUuid(
|
||||||
|
decoded.sourceUuid,
|
||||||
|
'MessageReceiver.handleRequest.sourceUuid'
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
sourceDevice: decoded.sourceDevice,
|
||||||
|
timestamp: normalizeNumber(decoded.timestamp),
|
||||||
|
legacyMessage: dropNull(decoded.legacyMessage),
|
||||||
|
content: dropNull(decoded.content),
|
||||||
|
serverGuid: decoded.serverGuid,
|
||||||
|
serverTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
// After this point, decoding errors are not the server's
|
||||||
|
// fault, and we should handle them gracefully and tell the
|
||||||
|
// user they received an invalid message
|
||||||
|
|
||||||
|
if (envelope.source && this.isBlocked(envelope.source)) {
|
||||||
|
request.respond(200, 'OK');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)) {
|
||||||
|
request.respond(200, 'OK');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.decryptAndCache(envelope, plaintext, request);
|
||||||
|
this.processedCount += 1;
|
||||||
|
} catch (e) {
|
||||||
|
request.respond(500, 'Bad encrypted websocket message');
|
||||||
|
window.log.error(
|
||||||
|
'Error handling incoming message:',
|
||||||
|
Errors.toLogFormat(e)
|
||||||
|
);
|
||||||
|
await this.dispatchAndWait(new ErrorEvent(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.incomingQueue.add(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
public reset(): void {
|
||||||
// We always process our cache before processing a new websocket message
|
// We always process our cache before processing a new websocket message
|
||||||
this.incomingQueue.add(async () => this.queueAllCached());
|
this.incomingQueue.add(async () => this.queueAllCached());
|
||||||
|
|
||||||
this.count = 0;
|
this.count = 0;
|
||||||
if (this.hasConnected) {
|
|
||||||
this.dispatchEvent(new ReconnectEvent());
|
|
||||||
}
|
|
||||||
|
|
||||||
this.isEmptied = false;
|
this.isEmptied = false;
|
||||||
|
this.stoppingProcessing = false;
|
||||||
this.hasConnected = true;
|
|
||||||
|
|
||||||
if (this.socket && this.socket.connected) {
|
|
||||||
this.socket.close();
|
|
||||||
this.socket = undefined;
|
|
||||||
if (this.wsr) {
|
|
||||||
this.wsr.close();
|
|
||||||
this.wsr = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.socketStatus = SocketStatus.CONNECTING;
|
|
||||||
|
|
||||||
// initialize the socket and start listening for messages
|
|
||||||
try {
|
|
||||||
this.socket = socket || (await this.server.getMessageSocket());
|
|
||||||
} catch (error) {
|
|
||||||
this.socketStatus = SocketStatus.CLOSED;
|
|
||||||
|
|
||||||
if (error instanceof ConnectTimeoutError) {
|
|
||||||
await this.onclose(-1, 'Connection timed out');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.dispatchAndWait(new ErrorEvent(error));
|
public stopProcessing(): void {
|
||||||
return;
|
this.stoppingProcessing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socketStatus = SocketStatus.OPEN;
|
public hasEmptied(): boolean {
|
||||||
|
return Boolean(this.isEmptied);
|
||||||
window.log.info('websocket open');
|
|
||||||
window.logMessageReceiverConnect();
|
|
||||||
|
|
||||||
if (!this._onClose) {
|
|
||||||
this._onClose = this.onclose.bind(this);
|
|
||||||
}
|
|
||||||
if (!this._onWSRClose) {
|
|
||||||
this._onWSRClose = ({ code, reason }: CloseEvent): void => {
|
|
||||||
this.onclose(code, reason);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!this._onError) {
|
|
||||||
this._onError = this.onerror.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.socket.on('close', this._onClose);
|
public async drain(): Promise<void> {
|
||||||
this.socket.on('error', this._onError);
|
const waitForEncryptedQueue = async () =>
|
||||||
|
this.addToQueue(async () => {
|
||||||
|
window.log.info('drained');
|
||||||
|
}, TaskType.Decrypted);
|
||||||
|
|
||||||
this.wsr = new WebSocketResource(this.socket, {
|
const waitForIncomingQueue = async () =>
|
||||||
handleRequest: this.handleRequest.bind(this),
|
this.addToQueue(waitForEncryptedQueue, TaskType.Encrypted);
|
||||||
keepalive: {
|
|
||||||
path: '/v1/keepalive',
|
|
||||||
disconnect: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Because sometimes the socket doesn't properly emit its close event
|
return this.incomingQueue.add(waitForIncomingQueue);
|
||||||
if (this._onWSRClose) {
|
|
||||||
this.wsr.addEventListener('close', this._onWSRClose);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public async close(): Promise<void> {
|
|
||||||
window.log.info('MessageReceiver.close()');
|
|
||||||
this.calledClose = true;
|
|
||||||
this.socketStatus = SocketStatus.CLOSING;
|
|
||||||
|
|
||||||
// Our WebSocketResource instance will close the socket and emit a 'close' event
|
|
||||||
// if the socket doesn't emit one quickly enough.
|
|
||||||
if (this.wsr) {
|
|
||||||
this.wsr.close(3000, 'called close');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.clearRetryTimeout();
|
|
||||||
|
|
||||||
return this.drain();
|
|
||||||
}
|
|
||||||
|
|
||||||
public checkSocket(): void {
|
|
||||||
if (this.wsr) {
|
|
||||||
this.wsr.forceKeepAlive();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getStatus(): SocketStatus {
|
|
||||||
return this.socketStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async downloadAttachment(
|
|
||||||
attachment: ProcessedAttachment
|
|
||||||
): Promise<DownloadedAttachmentType> {
|
|
||||||
const cdnId = attachment.cdnId || attachment.cdnKey;
|
|
||||||
const { cdnNumber } = attachment;
|
|
||||||
|
|
||||||
if (!cdnId) {
|
|
||||||
throw new Error('downloadAttachment: Attachment was missing cdnId!');
|
|
||||||
}
|
|
||||||
|
|
||||||
strictAssert(cdnId, 'attachment without cdnId');
|
|
||||||
const encrypted = await this.server.getAttachment(
|
|
||||||
cdnId,
|
|
||||||
dropNull(cdnNumber)
|
|
||||||
);
|
|
||||||
const { key, digest, size, contentType } = attachment;
|
|
||||||
|
|
||||||
if (!digest) {
|
|
||||||
throw new Error('Failure: Ask sender to update Signal and resend.');
|
|
||||||
}
|
|
||||||
|
|
||||||
strictAssert(key, 'attachment has no key');
|
|
||||||
strictAssert(digest, 'attachment has no digest');
|
|
||||||
|
|
||||||
const paddedData = await Crypto.decryptAttachment(
|
|
||||||
encrypted,
|
|
||||||
typedArrayToArrayBuffer(Bytes.fromBase64(key)),
|
|
||||||
typedArrayToArrayBuffer(Bytes.fromBase64(digest))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isNumber(size)) {
|
|
||||||
throw new Error(
|
|
||||||
`downloadAttachment: Size was not provided, actual size was ${paddedData.byteLength}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = window.Signal.Crypto.getFirstBytes(paddedData, size);
|
|
||||||
|
|
||||||
return {
|
|
||||||
...omit(attachment, 'digest', 'key'),
|
|
||||||
|
|
||||||
contentType: contentType
|
|
||||||
? MIME.fromString(contentType)
|
|
||||||
: MIME.APPLICATION_OCTET_STREAM,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
@ -548,157 +471,10 @@ export default class MessageReceiver extends EventTarget {
|
||||||
// Private
|
// Private
|
||||||
//
|
//
|
||||||
|
|
||||||
private shutdown(): void {
|
|
||||||
if (this.socket) {
|
|
||||||
if (this._onClose) {
|
|
||||||
this.socket.removeListener('close', this._onClose);
|
|
||||||
}
|
|
||||||
if (this._onError) {
|
|
||||||
this.socket.removeListener('error', this._onError);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.socket = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.wsr) {
|
|
||||||
if (this._onWSRClose) {
|
|
||||||
this.wsr.removeEventListener('close', this._onWSRClose);
|
|
||||||
}
|
|
||||||
this.wsr = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async onerror(error: Error): Promise<void> {
|
|
||||||
window.log.error('websocket error', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async dispatchAndWait(event: Event): Promise<void> {
|
private async dispatchAndWait(event: Event): Promise<void> {
|
||||||
this.appQueue.add(async () => Promise.all(this.dispatchEvent(event)));
|
this.appQueue.add(async () => Promise.all(this.dispatchEvent(event)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onclose(code: number, reason: string): Promise<void> {
|
|
||||||
window.log.info(
|
|
||||||
'MessageReceiver: websocket closed',
|
|
||||||
code,
|
|
||||||
reason || '',
|
|
||||||
'calledClose:',
|
|
||||||
this.calledClose
|
|
||||||
);
|
|
||||||
|
|
||||||
this.socketStatus = SocketStatus.CLOSED;
|
|
||||||
|
|
||||||
this.shutdown();
|
|
||||||
|
|
||||||
if (this.calledClose) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (code === 3000) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (code === 3001) {
|
|
||||||
this.onEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = this.reconnectBackOff.getAndIncrement();
|
|
||||||
|
|
||||||
window.log.info(`MessageReceiver: reconnecting after ${timeout}ms`);
|
|
||||||
await sleep(timeout);
|
|
||||||
|
|
||||||
// Try to reconnect (if there is an HTTP error - we'll get an
|
|
||||||
// `error` event from `connect()` and hit the secondary retry backoff
|
|
||||||
// logic in `ts/background.ts`)
|
|
||||||
await this.connect();
|
|
||||||
|
|
||||||
// Successfull reconnect, reset the backoff timeouts
|
|
||||||
this.reconnectBackOff.reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRequest(request: IncomingWebSocketRequest): void {
|
|
||||||
// We do the message decryption here, instead of in the ordered pending queue,
|
|
||||||
// to avoid exposing the time it took us to process messages through the time-to-ack.
|
|
||||||
|
|
||||||
if (request.path !== '/api/v1/message') {
|
|
||||||
window.log.info('got request', request.verb, request.path);
|
|
||||||
request.respond(200, 'OK');
|
|
||||||
|
|
||||||
if (request.verb === 'PUT' && request.path === '/api/v1/queue/empty') {
|
|
||||||
this.incomingQueue.add(() => {
|
|
||||||
this.onEmpty();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const job = async () => {
|
|
||||||
const headers = request.headers || [];
|
|
||||||
|
|
||||||
if (!request.body) {
|
|
||||||
throw new Error(
|
|
||||||
'MessageReceiver.handleRequest: request.body was falsey!'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plaintext = request.body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const decoded = Proto.Envelope.decode(plaintext);
|
|
||||||
const serverTimestamp = normalizeNumber(decoded.serverTimestamp);
|
|
||||||
|
|
||||||
const envelope: ProcessedEnvelope = {
|
|
||||||
// Make non-private envelope IDs dashless so they don't get redacted
|
|
||||||
// from logs
|
|
||||||
id: getGuid().replace(/-/g, ''),
|
|
||||||
receivedAtCounter: window.Signal.Util.incrementMessageCounter(),
|
|
||||||
receivedAtDate: Date.now(),
|
|
||||||
// Calculate the message age (time on server).
|
|
||||||
messageAgeSec: this.calculateMessageAge(headers, serverTimestamp),
|
|
||||||
|
|
||||||
// Proto.Envelope fields
|
|
||||||
type: decoded.type,
|
|
||||||
source: decoded.source,
|
|
||||||
sourceUuid: decoded.sourceUuid
|
|
||||||
? normalizeUuid(
|
|
||||||
decoded.sourceUuid,
|
|
||||||
'MessageReceiver.handleRequest.sourceUuid'
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
sourceDevice: decoded.sourceDevice,
|
|
||||||
timestamp: normalizeNumber(decoded.timestamp),
|
|
||||||
legacyMessage: dropNull(decoded.legacyMessage),
|
|
||||||
content: dropNull(decoded.content),
|
|
||||||
serverGuid: decoded.serverGuid,
|
|
||||||
serverTimestamp,
|
|
||||||
};
|
|
||||||
|
|
||||||
// After this point, decoding errors are not the server's
|
|
||||||
// fault, and we should handle them gracefully and tell the
|
|
||||||
// user they received an invalid message
|
|
||||||
|
|
||||||
if (envelope.source && this.isBlocked(envelope.source)) {
|
|
||||||
request.respond(200, 'OK');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (envelope.sourceUuid && this.isUuidBlocked(envelope.sourceUuid)) {
|
|
||||||
request.respond(200, 'OK');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.decryptAndCache(envelope, plaintext, request);
|
|
||||||
this.processedCount += 1;
|
|
||||||
} catch (e) {
|
|
||||||
request.respond(500, 'Bad encrypted websocket message');
|
|
||||||
window.log.error(
|
|
||||||
'Error handling incoming message:',
|
|
||||||
Errors.toLogFormat(e)
|
|
||||||
);
|
|
||||||
await this.dispatchAndWait(new ErrorEvent(e));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.incomingQueue.add(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateMessageAge(
|
private calculateMessageAge(
|
||||||
headers: ReadonlyArray<string>,
|
headers: ReadonlyArray<string>,
|
||||||
serverTimestamp?: number
|
serverTimestamp?: number
|
||||||
|
@ -748,10 +524,6 @@ export default class MessageReceiver extends EventTarget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public hasEmptied(): boolean {
|
|
||||||
return Boolean(this.isEmptied);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onEmpty(): void {
|
private onEmpty(): void {
|
||||||
const emitEmpty = async () => {
|
const emitEmpty = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
@ -795,18 +567,6 @@ export default class MessageReceiver extends EventTarget {
|
||||||
waitForCacheAddBatcher();
|
waitForCacheAddBatcher();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async drain(): Promise<void> {
|
|
||||||
const waitForEncryptedQueue = async () =>
|
|
||||||
this.addToQueue(async () => {
|
|
||||||
window.log.info('drained');
|
|
||||||
}, TaskType.Decrypted);
|
|
||||||
|
|
||||||
const waitForIncomingQueue = async () =>
|
|
||||||
this.addToQueue(waitForEncryptedQueue, TaskType.Encrypted);
|
|
||||||
|
|
||||||
return this.incomingQueue.add(waitForIncomingQueue);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateProgress(count: number): void {
|
private updateProgress(count: number): void {
|
||||||
// count by 10s
|
// count by 10s
|
||||||
if (count % 10 !== 0) {
|
if (count % 10 !== 0) {
|
||||||
|
@ -890,7 +650,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = item;
|
const { id } = item;
|
||||||
await window.textsecure.storage.protocol.removeUnprocessed(id);
|
await this.storage.protocol.removeUnprocessed(id);
|
||||||
} catch (deleteError) {
|
} catch (deleteError) {
|
||||||
window.log.error(
|
window.log.error(
|
||||||
'queueCached error deleting item',
|
'queueCached error deleting item',
|
||||||
|
@ -931,17 +691,17 @@ export default class MessageReceiver extends EventTarget {
|
||||||
|
|
||||||
private async getAllFromCache(): Promise<Array<UnprocessedType>> {
|
private async getAllFromCache(): Promise<Array<UnprocessedType>> {
|
||||||
window.log.info('getAllFromCache');
|
window.log.info('getAllFromCache');
|
||||||
const count = await window.textsecure.storage.protocol.getUnprocessedCount();
|
const count = await this.storage.protocol.getUnprocessedCount();
|
||||||
|
|
||||||
if (count > 1500) {
|
if (count > 1500) {
|
||||||
await window.textsecure.storage.protocol.removeAllUnprocessed();
|
await this.storage.protocol.removeAllUnprocessed();
|
||||||
window.log.warn(
|
window.log.warn(
|
||||||
`There were ${count} messages in cache. Deleted all instead of reprocessing`
|
`There were ${count} messages in cache. Deleted all instead of reprocessing`
|
||||||
);
|
);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = await window.textsecure.storage.protocol.getAllUnprocessed();
|
const items = await this.storage.protocol.getAllUnprocessed();
|
||||||
window.log.info('getAllFromCache loaded', items.length, 'saved envelopes');
|
window.log.info('getAllFromCache loaded', items.length, 'saved envelopes');
|
||||||
|
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
|
@ -954,9 +714,9 @@ export default class MessageReceiver extends EventTarget {
|
||||||
'getAllFromCache final attempt for envelope',
|
'getAllFromCache final attempt for envelope',
|
||||||
item.id
|
item.id
|
||||||
);
|
);
|
||||||
await window.textsecure.storage.protocol.removeUnprocessed(item.id);
|
await this.storage.protocol.removeUnprocessed(item.id);
|
||||||
} else {
|
} else {
|
||||||
await window.textsecure.storage.protocol.updateUnprocessedAttempts(
|
await this.storage.protocol.updateUnprocessedAttempts(
|
||||||
item.id,
|
item.id,
|
||||||
attempts
|
attempts
|
||||||
);
|
);
|
||||||
|
@ -986,7 +746,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
}>
|
}>
|
||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
const storageProtocol = window.textsecure.storage.protocol;
|
const storageProtocol = this.storage.protocol;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const zone = new Zone('decryptAndCacheBatch', {
|
const zone = new Zone('decryptAndCacheBatch', {
|
||||||
|
@ -1124,7 +884,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async cacheRemoveBatch(items: Array<string>): Promise<void> {
|
private async cacheRemoveBatch(items: Array<string>): Promise<void> {
|
||||||
await window.textsecure.storage.protocol.removeUnprocessed(items);
|
await this.storage.protocol.removeUnprocessed(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
private removeFromCache(envelope: ProcessedEnvelope): void {
|
private removeFromCache(envelope: ProcessedEnvelope): void {
|
||||||
|
@ -1140,7 +900,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
window.log.info('queueing decrypted envelope', id);
|
window.log.info('queueing decrypted envelope', id);
|
||||||
|
|
||||||
const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext);
|
const task = this.handleDecryptedEnvelope.bind(this, envelope, plaintext);
|
||||||
const taskWithTimeout = window.textsecure.createTaskWithTimeout(
|
const taskWithTimeout = createTaskWithTimeout(
|
||||||
task,
|
task,
|
||||||
`queueDecryptedEnvelope ${id}`
|
`queueDecryptedEnvelope ${id}`
|
||||||
);
|
);
|
||||||
|
@ -1163,7 +923,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
window.log.info('queueing envelope', id);
|
window.log.info('queueing envelope', id);
|
||||||
|
|
||||||
const task = this.decryptEnvelope.bind(this, stores, envelope);
|
const task = this.decryptEnvelope.bind(this, stores, envelope);
|
||||||
const taskWithTimeout = window.textsecure.createTaskWithTimeout(
|
const taskWithTimeout = createTaskWithTimeout(
|
||||||
task,
|
task,
|
||||||
`queueEncryptedEnvelope ${id}`
|
`queueEncryptedEnvelope ${id}`
|
||||||
);
|
);
|
||||||
|
@ -1456,10 +1216,10 @@ export default class MessageReceiver extends EventTarget {
|
||||||
envelope: UnsealedEnvelope,
|
envelope: UnsealedEnvelope,
|
||||||
ciphertext: Uint8Array
|
ciphertext: Uint8Array
|
||||||
): Promise<DecryptSealedSenderResult> {
|
): Promise<DecryptSealedSenderResult> {
|
||||||
const localE164 = window.textsecure.storage.user.getNumber();
|
const localE164 = this.storage.user.getNumber();
|
||||||
const localUuid = window.textsecure.storage.user.getUuid();
|
const localUuid = this.storage.user.getUuid();
|
||||||
const localDeviceId = parseIntOrThrow(
|
const localDeviceId = parseIntOrThrow(
|
||||||
window.textsecure.storage.user.getDeviceId(),
|
this.storage.user.getDeviceId(),
|
||||||
'MessageReceiver.decryptSealedSender: localDeviceId'
|
'MessageReceiver.decryptSealedSender: localDeviceId'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -1513,7 +1273,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
|
|
||||||
const address = `${sealedSenderIdentifier}.${sealedSenderSourceDevice}`;
|
const address = `${sealedSenderIdentifier}.${sealedSenderSourceDevice}`;
|
||||||
|
|
||||||
const plaintext = await window.textsecure.storage.protocol.enqueueSenderKeyJob(
|
const plaintext = await this.storage.protocol.enqueueSenderKeyJob(
|
||||||
address,
|
address,
|
||||||
() =>
|
() =>
|
||||||
groupDecrypt(
|
groupDecrypt(
|
||||||
|
@ -1539,7 +1299,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
|
|
||||||
const sealedSenderIdentifier = envelope.sourceUuid || envelope.source;
|
const sealedSenderIdentifier = envelope.sourceUuid || envelope.source;
|
||||||
const address = `${sealedSenderIdentifier}.${envelope.sourceDevice}`;
|
const address = `${sealedSenderIdentifier}.${envelope.sourceDevice}`;
|
||||||
const unsealedPlaintext = await window.textsecure.storage.protocol.enqueueSessionJob(
|
const unsealedPlaintext = await this.storage.protocol.enqueueSessionJob(
|
||||||
address,
|
address,
|
||||||
() =>
|
() =>
|
||||||
sealedSenderDecryptMessage(
|
sealedSenderDecryptMessage(
|
||||||
|
@ -1598,7 +1358,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
const signalMessage = SignalMessage.deserialize(Buffer.from(ciphertext));
|
const signalMessage = SignalMessage.deserialize(Buffer.from(ciphertext));
|
||||||
|
|
||||||
const address = `${identifier}.${sourceDevice}`;
|
const address = `${identifier}.${sourceDevice}`;
|
||||||
const plaintext = await window.textsecure.storage.protocol.enqueueSessionJob(
|
const plaintext = await this.storage.protocol.enqueueSessionJob(
|
||||||
address,
|
address,
|
||||||
async () =>
|
async () =>
|
||||||
this.unpad(
|
this.unpad(
|
||||||
|
@ -1630,7 +1390,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
);
|
);
|
||||||
|
|
||||||
const address = `${identifier}.${sourceDevice}`;
|
const address = `${identifier}.${sourceDevice}`;
|
||||||
const plaintext = await window.textsecure.storage.protocol.enqueueSessionJob(
|
const plaintext = await this.storage.protocol.enqueueSessionJob(
|
||||||
address,
|
address,
|
||||||
async () =>
|
async () =>
|
||||||
this.unpad(
|
this.unpad(
|
||||||
|
@ -1780,8 +1540,8 @@ export default class MessageReceiver extends EventTarget {
|
||||||
const groupId = this.getProcessedGroupId(message);
|
const groupId = this.getProcessedGroupId(message);
|
||||||
const isBlocked = groupId ? this.isGroupBlocked(groupId) : false;
|
const isBlocked = groupId ? this.isGroupBlocked(groupId) : false;
|
||||||
const { source, sourceUuid } = envelope;
|
const { source, sourceUuid } = envelope;
|
||||||
const ourE164 = window.textsecure.storage.user.getNumber();
|
const ourE164 = this.storage.user.getNumber();
|
||||||
const ourUuid = window.textsecure.storage.user.getUuid();
|
const ourUuid = this.storage.user.getUuid();
|
||||||
const isMe =
|
const isMe =
|
||||||
(source && ourE164 && source === ourE164) ||
|
(source && ourE164 && source === ourE164) ||
|
||||||
(sourceUuid && ourUuid && sourceUuid === ourUuid);
|
(sourceUuid && ourUuid && sourceUuid === ourUuid);
|
||||||
|
@ -1869,8 +1629,8 @@ export default class MessageReceiver extends EventTarget {
|
||||||
const groupId = this.getProcessedGroupId(message);
|
const groupId = this.getProcessedGroupId(message);
|
||||||
const isBlocked = groupId ? this.isGroupBlocked(groupId) : false;
|
const isBlocked = groupId ? this.isGroupBlocked(groupId) : false;
|
||||||
const { source, sourceUuid } = envelope;
|
const { source, sourceUuid } = envelope;
|
||||||
const ourE164 = window.textsecure.storage.user.getNumber();
|
const ourE164 = this.storage.user.getNumber();
|
||||||
const ourUuid = window.textsecure.storage.user.getUuid();
|
const ourUuid = this.storage.user.getUuid();
|
||||||
const isMe =
|
const isMe =
|
||||||
(source && ourE164 && source === ourE164) ||
|
(source && ourE164 && source === ourE164) ||
|
||||||
(sourceUuid && ourUuid && sourceUuid === ourUuid);
|
(sourceUuid && ourUuid && sourceUuid === ourUuid);
|
||||||
|
@ -2086,7 +1846,7 @@ export default class MessageReceiver extends EventTarget {
|
||||||
const senderKeyStore = new SenderKeys();
|
const senderKeyStore = new SenderKeys();
|
||||||
const address = `${identifier}.${sourceDevice}`;
|
const address = `${identifier}.${sourceDevice}`;
|
||||||
|
|
||||||
await window.textsecure.storage.protocol.enqueueSenderKeyJob(
|
await this.storage.protocol.enqueueSenderKeyJob(
|
||||||
address,
|
address,
|
||||||
() =>
|
() =>
|
||||||
processSenderKeyDistributionMessage(
|
processSenderKeyDistributionMessage(
|
||||||
|
@ -2326,15 +2086,19 @@ export default class MessageReceiver extends EventTarget {
|
||||||
envelope: ProcessedEnvelope,
|
envelope: ProcessedEnvelope,
|
||||||
syncMessage: ProcessedSyncMessage
|
syncMessage: ProcessedSyncMessage
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fromSelfSource =
|
const ourNumber = this.storage.user.getNumber();
|
||||||
envelope.source && envelope.source === this.number_id;
|
const ourUuid = this.storage.user.getUuid();
|
||||||
|
|
||||||
|
const fromSelfSource = envelope.source && envelope.source === ourNumber;
|
||||||
const fromSelfSourceUuid =
|
const fromSelfSourceUuid =
|
||||||
envelope.sourceUuid && envelope.sourceUuid === this.uuid_id;
|
envelope.sourceUuid && envelope.sourceUuid === ourUuid;
|
||||||
if (!fromSelfSource && !fromSelfSourceUuid) {
|
if (!fromSelfSource && !fromSelfSourceUuid) {
|
||||||
throw new Error('Received sync message from another number');
|
throw new Error('Received sync message from another number');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ourDeviceId = this.storage.user.getDeviceId();
|
||||||
// eslint-disable-next-line eqeqeq
|
// eslint-disable-next-line eqeqeq
|
||||||
if (envelope.sourceDevice == this.deviceId) {
|
if (envelope.sourceDevice == ourDeviceId) {
|
||||||
throw new Error('Received sync message from our own device');
|
throw new Error('Received sync message from our own device');
|
||||||
}
|
}
|
||||||
if (syncMessage.sent) {
|
if (syncMessage.sent) {
|
||||||
|
@ -2681,14 +2445,14 @@ export default class MessageReceiver extends EventTarget {
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
window.log.info('Setting these numbers as blocked:', blocked.numbers);
|
window.log.info('Setting these numbers as blocked:', blocked.numbers);
|
||||||
if (blocked.numbers) {
|
if (blocked.numbers) {
|
||||||
await window.textsecure.storage.put('blocked', blocked.numbers);
|
await this.storage.put('blocked', blocked.numbers);
|
||||||
}
|
}
|
||||||
if (blocked.uuids) {
|
if (blocked.uuids) {
|
||||||
const uuids = blocked.uuids.map((uuid, index) => {
|
const uuids = blocked.uuids.map((uuid, index) => {
|
||||||
return normalizeUuid(uuid, `handleBlocked.uuids.${index}`);
|
return normalizeUuid(uuid, `handleBlocked.uuids.${index}`);
|
||||||
});
|
});
|
||||||
window.log.info('Setting these uuids as blocked:', uuids);
|
window.log.info('Setting these uuids as blocked:', uuids);
|
||||||
await window.textsecure.storage.put('blocked-uuids', uuids);
|
await this.storage.put('blocked-uuids', uuids);
|
||||||
}
|
}
|
||||||
|
|
||||||
const groupIds = map(blocked.groupIds, groupId => Bytes.toBinary(groupId));
|
const groupIds = map(blocked.groupIds, groupId => Bytes.toBinary(groupId));
|
||||||
|
@ -2696,33 +2460,33 @@ export default class MessageReceiver extends EventTarget {
|
||||||
'Setting these groups as blocked:',
|
'Setting these groups as blocked:',
|
||||||
groupIds.map(groupId => `group(${groupId})`)
|
groupIds.map(groupId => `group(${groupId})`)
|
||||||
);
|
);
|
||||||
await window.textsecure.storage.put('blocked-groups', groupIds);
|
await this.storage.put('blocked-groups', groupIds);
|
||||||
|
|
||||||
this.removeFromCache(envelope);
|
this.removeFromCache(envelope);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isBlocked(number: string): boolean {
|
private isBlocked(number: string): boolean {
|
||||||
return window.textsecure.storage.blocked.isBlocked(number);
|
return this.storage.blocked.isBlocked(number);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isUuidBlocked(uuid: string): boolean {
|
private isUuidBlocked(uuid: string): boolean {
|
||||||
return window.textsecure.storage.blocked.isUuidBlocked(uuid);
|
return this.storage.blocked.isUuidBlocked(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isGroupBlocked(groupId: string): boolean {
|
private isGroupBlocked(groupId: string): boolean {
|
||||||
return window.textsecure.storage.blocked.isGroupBlocked(groupId);
|
return this.storage.blocked.isGroupBlocked(groupId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleAttachment(
|
private async handleAttachment(
|
||||||
attachment: Proto.IAttachmentPointer
|
attachment: Proto.IAttachmentPointer
|
||||||
): Promise<DownloadedAttachmentType> {
|
): Promise<DownloadedAttachmentType> {
|
||||||
const cleaned = processAttachment(attachment);
|
const cleaned = processAttachment(attachment);
|
||||||
return this.downloadAttachment(cleaned);
|
return downloadAttachment(this.server, cleaned);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleEndSession(identifier: string): Promise<void> {
|
private async handleEndSession(identifier: string): Promise<void> {
|
||||||
window.log.info(`handleEndSession: closing sessions for ${identifier}`);
|
window.log.info(`handleEndSession: closing sessions for ${identifier}`);
|
||||||
await window.textsecure.storage.protocol.archiveAllSessions(identifier);
|
await this.storage.protocol.archiveAllSessions(identifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async processDecrypted(
|
private async processDecrypted(
|
||||||
|
|
627
ts/textsecure/SocketManager.ts
Normal file
627
ts/textsecure/SocketManager.ts
Normal file
|
@ -0,0 +1,627 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
|
||||||
|
import URL from 'url';
|
||||||
|
import ProxyAgent from 'proxy-agent';
|
||||||
|
import { RequestInit, Response, Headers } from 'node-fetch';
|
||||||
|
import { client as WebSocketClient } from 'websocket';
|
||||||
|
import qs from 'querystring';
|
||||||
|
import EventListener from 'events';
|
||||||
|
|
||||||
|
import { AbortableProcess } from '../util/AbortableProcess';
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
import { explodePromise } from '../util/explodePromise';
|
||||||
|
import { BackOff, FIBONACCI_TIMEOUTS } from '../util/BackOff';
|
||||||
|
import { getUserAgent } from '../util/getUserAgent';
|
||||||
|
import { sleep } from '../util/sleep';
|
||||||
|
import { SocketStatus } from '../types/SocketStatus';
|
||||||
|
import * as Errors from '../types/errors';
|
||||||
|
import * as Bytes from '../Bytes';
|
||||||
|
|
||||||
|
import WebSocketResource, {
|
||||||
|
WebSocketResourceOptions,
|
||||||
|
IncomingWebSocketRequest,
|
||||||
|
} from './WebsocketResources';
|
||||||
|
import { ConnectTimeoutError, HTTPError } from './Errors';
|
||||||
|
import { handleStatusCode, translateError } from './Utils';
|
||||||
|
import { WebAPICredentials, IRequestHandler } from './Types.d';
|
||||||
|
|
||||||
|
// TODO: remove once we move away from ArrayBuffers
|
||||||
|
const FIXMEU8 = Uint8Array;
|
||||||
|
|
||||||
|
const TEN_SECONDS = 1000 * 10;
|
||||||
|
|
||||||
|
const FIVE_MINUTES = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
export type SocketManagerOptions = Readonly<{
|
||||||
|
url: string;
|
||||||
|
certificateAuthority: string;
|
||||||
|
version: string;
|
||||||
|
proxyUrl?: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// This class manages two websocket resource:
|
||||||
|
//
|
||||||
|
// - Authenticated WebSocketResource which uses supplied WebAPICredentials and
|
||||||
|
// automatically reconnects on closed socket (using back off)
|
||||||
|
// - Unauthenticated WebSocketResource that is created on demand and reconnected
|
||||||
|
// every 5 minutes.
|
||||||
|
//
|
||||||
|
// Incoming requests on authenticated resource are funneled into the registered
|
||||||
|
// request handlers (`registerRequestHandler`) or queued internally until at
|
||||||
|
// least one such request handler becomes available.
|
||||||
|
//
|
||||||
|
// Incoming requests on unauthenticated resource are not currently supported.
|
||||||
|
// WebSocketResource is responsible for their immediate termination.
|
||||||
|
export class SocketManager extends EventListener {
|
||||||
|
private backOff = new BackOff(FIBONACCI_TIMEOUTS);
|
||||||
|
|
||||||
|
private authenticated?: AbortableProcess<WebSocketResource>;
|
||||||
|
|
||||||
|
private unauthenticated?: AbortableProcess<WebSocketResource>;
|
||||||
|
|
||||||
|
private credentials?: WebAPICredentials;
|
||||||
|
|
||||||
|
private readonly proxyAgent?: ProxyAgent;
|
||||||
|
|
||||||
|
private status = SocketStatus.CLOSED;
|
||||||
|
|
||||||
|
private requestHandlers = new Set<IRequestHandler>();
|
||||||
|
|
||||||
|
private incomingRequestQueue = new Array<IncomingWebSocketRequest>();
|
||||||
|
|
||||||
|
private isOffline = false;
|
||||||
|
|
||||||
|
constructor(private readonly options: SocketManagerOptions) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
if (options.proxyUrl) {
|
||||||
|
this.proxyAgent = new ProxyAgent(options.proxyUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getStatus(): SocketStatus {
|
||||||
|
return this.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update WebAPICredentials and reconnect authenticated resource if
|
||||||
|
// credentials changed
|
||||||
|
public async authenticate(credentials: WebAPICredentials): Promise<void> {
|
||||||
|
if (this.isOffline) {
|
||||||
|
throw new HTTPError('SocketManager offline', {
|
||||||
|
code: 0,
|
||||||
|
headers: {},
|
||||||
|
stack: new Error().stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { username, password } = credentials;
|
||||||
|
if (!username && !password) {
|
||||||
|
window.log.warn(
|
||||||
|
'SocketManager authenticate was called without credentials'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.credentials &&
|
||||||
|
this.credentials.username === username &&
|
||||||
|
this.credentials.password === password &&
|
||||||
|
this.authenticated
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await this.authenticated.getResult();
|
||||||
|
} catch (error) {
|
||||||
|
window.log.warn(
|
||||||
|
'SocketManager: failed to wait for existing authenticated socket ' +
|
||||||
|
` due to error: ${Errors.toLogFormat(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.credentials = credentials;
|
||||||
|
|
||||||
|
window.log.info('SocketManager: connecting authenticated socket');
|
||||||
|
|
||||||
|
this.status = SocketStatus.CONNECTING;
|
||||||
|
|
||||||
|
const process = this.connectResource({
|
||||||
|
path: '/v1/websocket/',
|
||||||
|
query: { login: username, password },
|
||||||
|
resourceOptions: {
|
||||||
|
keepalive: { path: '/v1/keepalive' },
|
||||||
|
handleRequest: (req: IncomingWebSocketRequest): void => {
|
||||||
|
this.queueOrHandleRequest(req);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel previous connect attempt or close socket
|
||||||
|
this.authenticated?.abort();
|
||||||
|
|
||||||
|
this.authenticated = process;
|
||||||
|
|
||||||
|
const reconnect = async (): Promise<void> => {
|
||||||
|
const timeout = this.backOff.getAndIncrement();
|
||||||
|
|
||||||
|
window.log.info(
|
||||||
|
'SocketManager: reconnecting authenticated socket ' +
|
||||||
|
`after ${timeout}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
await sleep(timeout);
|
||||||
|
if (this.isOffline) {
|
||||||
|
window.log.info(
|
||||||
|
'SocketManager: cancelled reconnect because we are offline'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.authenticated) {
|
||||||
|
window.log.info(
|
||||||
|
'SocketManager: authenticated socket already reconnected'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
strictAssert(this.credentials !== undefined, 'Missing credentials');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.authenticate(this.credentials);
|
||||||
|
} catch (error) {
|
||||||
|
window.log.info(
|
||||||
|
'SocketManager: authenticated socket failed to reconect ' +
|
||||||
|
`due to error ${Errors.toLogFormat(error)}`
|
||||||
|
);
|
||||||
|
return reconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let authenticated: WebSocketResource;
|
||||||
|
try {
|
||||||
|
authenticated = await process.getResult();
|
||||||
|
this.status = SocketStatus.OPEN;
|
||||||
|
} catch (error) {
|
||||||
|
strictAssert(this.authenticated === process, 'Someone stole our socket');
|
||||||
|
this.dropAuthenticated(process);
|
||||||
|
|
||||||
|
window.log.warn(
|
||||||
|
'SocketManager: authenticated socket connection failed with ' +
|
||||||
|
`error: ${Errors.toLogFormat(error)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error instanceof HTTPError) {
|
||||||
|
const { code } = error;
|
||||||
|
|
||||||
|
if (code === 401 || code === 403) {
|
||||||
|
this.emit('authError', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 500 && code !== -1) {
|
||||||
|
// No reconnect attempt should be made
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.info('SocketManager: connected authenticated socket');
|
||||||
|
|
||||||
|
window.logAuthenticatedConnect();
|
||||||
|
this.backOff.reset();
|
||||||
|
|
||||||
|
authenticated.addEventListener('close', ({ code, reason }): void => {
|
||||||
|
if (this.authenticated !== process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.warn(
|
||||||
|
'SocketManager: authenticated socket closed ' +
|
||||||
|
`with code=${code} and reason=${reason}`
|
||||||
|
);
|
||||||
|
this.dropAuthenticated(process);
|
||||||
|
|
||||||
|
if (code === 3000) {
|
||||||
|
// Intentional disconnect
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnect();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Either returns currently connecting/active authenticated
|
||||||
|
// WebSocketResource or connects a fresh one.
|
||||||
|
public async getAuthenticatedResource(): Promise<WebSocketResource> {
|
||||||
|
if (!this.authenticated) {
|
||||||
|
strictAssert(this.credentials !== undefined, 'Missing credentials');
|
||||||
|
await this.authenticate(this.credentials);
|
||||||
|
}
|
||||||
|
|
||||||
|
strictAssert(this.authenticated !== undefined, 'Authentication failed');
|
||||||
|
return this.authenticated.getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creates new WebSocketResource for AccountManager's provisioning
|
||||||
|
public async getProvisioningResource(
|
||||||
|
handler: IRequestHandler
|
||||||
|
): Promise<WebSocketResource> {
|
||||||
|
return this.connectResource({
|
||||||
|
path: '/v1/websocket/provisioning/',
|
||||||
|
resourceOptions: {
|
||||||
|
handleRequest: (req: IncomingWebSocketRequest): void => {
|
||||||
|
handler.handleRequest(req);
|
||||||
|
},
|
||||||
|
keepalive: { path: '/v1/keepalive/provisioning' },
|
||||||
|
},
|
||||||
|
}).getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch-compatible wrapper around underlying unauthenticated/authenticated
|
||||||
|
// websocket resources. This wrapper supports only limited number of features
|
||||||
|
// of node-fetch despite being API compatible.
|
||||||
|
public async fetch(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
const headers = new Headers(init.headers);
|
||||||
|
|
||||||
|
const isAuthenticated = headers.has('Authorization');
|
||||||
|
|
||||||
|
let resource: WebSocketResource;
|
||||||
|
if (isAuthenticated) {
|
||||||
|
resource = await this.getAuthenticatedResource();
|
||||||
|
} else {
|
||||||
|
resource = await this.getUnauthenticatedResource();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = URL.parse(url);
|
||||||
|
strictAssert(path, "Fetch can't have empty path");
|
||||||
|
|
||||||
|
const { method = 'GET', body, timeout } = init;
|
||||||
|
|
||||||
|
let bodyBytes: Uint8Array | undefined;
|
||||||
|
if (body === undefined) {
|
||||||
|
bodyBytes = undefined;
|
||||||
|
} else if (body instanceof Uint8Array) {
|
||||||
|
bodyBytes = body;
|
||||||
|
} else if (body instanceof ArrayBuffer) {
|
||||||
|
bodyBytes = new FIXMEU8(body);
|
||||||
|
} else if (typeof body === 'string') {
|
||||||
|
bodyBytes = Bytes.fromString(body);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unsupported body type: ${typeof body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
status,
|
||||||
|
message: statusText,
|
||||||
|
response,
|
||||||
|
headers: flatResponseHeaders,
|
||||||
|
} = await resource.sendRequest({
|
||||||
|
verb: method,
|
||||||
|
path,
|
||||||
|
body: bodyBytes,
|
||||||
|
headers: Array.from(headers.entries()).map(([key, value]) => {
|
||||||
|
return `${key}:${value}`;
|
||||||
|
}),
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseHeaders: Array<[string, string]> = flatResponseHeaders.map(
|
||||||
|
header => {
|
||||||
|
const [key, value] = header.split(':', 2);
|
||||||
|
strictAssert(value !== undefined, 'Invalid header!');
|
||||||
|
return [key, value];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Response(response, {
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public registerRequestHandler(handler: IRequestHandler): void {
|
||||||
|
this.requestHandlers.add(handler);
|
||||||
|
|
||||||
|
const queue = this.incomingRequestQueue;
|
||||||
|
if (queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.info(
|
||||||
|
`SocketManager: processing ${queue.length} queued incoming requests`
|
||||||
|
);
|
||||||
|
this.incomingRequestQueue = [];
|
||||||
|
for (const req of queue) {
|
||||||
|
this.queueOrHandleRequest(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public unregisterRequestHandler(handler: IRequestHandler): void {
|
||||||
|
this.requestHandlers.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force keep-alive checks on WebSocketResources
|
||||||
|
public async check(): Promise<void> {
|
||||||
|
if (this.isOffline) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.info('SocketManager.check');
|
||||||
|
await Promise.all([
|
||||||
|
SocketManager.checkResource(this.authenticated),
|
||||||
|
SocketManager.checkResource(this.unauthenticated),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puts SocketManager into "online" state and reconnects the authenticated
|
||||||
|
// WebSocketResource (if there are valid credentials)
|
||||||
|
public async onOnline(): Promise<void> {
|
||||||
|
window.log.info('SocketManager.onOnline');
|
||||||
|
this.isOffline = false;
|
||||||
|
|
||||||
|
if (this.credentials) {
|
||||||
|
await this.authenticate(this.credentials);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Puts SocketManager into "offline" state and gracefully disconnects both
|
||||||
|
// unauthenticated and authenticated resources.
|
||||||
|
public async onOffline(): Promise<void> {
|
||||||
|
window.log.info('SocketManager.onOffline');
|
||||||
|
this.isOffline = true;
|
||||||
|
|
||||||
|
this.authenticated?.abort();
|
||||||
|
this.unauthenticated?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Private
|
||||||
|
//
|
||||||
|
|
||||||
|
private async getUnauthenticatedResource(): Promise<WebSocketResource> {
|
||||||
|
if (this.isOffline) {
|
||||||
|
throw new HTTPError('SocketManager offline', {
|
||||||
|
code: 0,
|
||||||
|
headers: {},
|
||||||
|
stack: new Error().stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.unauthenticated) {
|
||||||
|
return this.unauthenticated.getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.info('SocketManager: connecting unauthenticated socket');
|
||||||
|
|
||||||
|
const process = this.connectResource({
|
||||||
|
path: '/v1/websocket/',
|
||||||
|
resourceOptions: {
|
||||||
|
keepalive: { path: '/v1/keepalive' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.unauthenticated = process;
|
||||||
|
|
||||||
|
const unauthenticated = await this.unauthenticated.getResult();
|
||||||
|
|
||||||
|
window.log.info('SocketManager: connected unauthenticated socket');
|
||||||
|
|
||||||
|
let timer: NodeJS.Timeout | undefined = setTimeout(() => {
|
||||||
|
window.log.info(
|
||||||
|
'SocketManager: shutting down unauthenticated socket after timeout'
|
||||||
|
);
|
||||||
|
timer = undefined;
|
||||||
|
unauthenticated.shutdown();
|
||||||
|
this.dropUnauthenticated(process);
|
||||||
|
}, FIVE_MINUTES);
|
||||||
|
|
||||||
|
unauthenticated.addEventListener('close', ({ code, reason }): void => {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.unauthenticated !== process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.warn(
|
||||||
|
'SocketManager: unauthenticated socket closed ' +
|
||||||
|
`with code=${code} and reason=${reason}`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dropUnauthenticated(process);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.unauthenticated.getResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectResource({
|
||||||
|
path,
|
||||||
|
resourceOptions,
|
||||||
|
query = {},
|
||||||
|
timeout = TEN_SECONDS,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
resourceOptions: WebSocketResourceOptions;
|
||||||
|
query?: Record<string, string>;
|
||||||
|
timeout?: number;
|
||||||
|
}): AbortableProcess<WebSocketResource> {
|
||||||
|
const fixedScheme = this.options.url
|
||||||
|
.replace('https://', 'wss://')
|
||||||
|
.replace('http://', 'ws://');
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'User-Agent': getUserAgent(this.options.version),
|
||||||
|
};
|
||||||
|
const client = new WebSocketClient({
|
||||||
|
tlsOptions: {
|
||||||
|
ca: this.options.certificateAuthority,
|
||||||
|
agent: this.proxyAgent,
|
||||||
|
},
|
||||||
|
maxReceivedFrameSize: 0x210000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryWithDefaults = {
|
||||||
|
agent: 'OWD',
|
||||||
|
version: this.options.version,
|
||||||
|
...query,
|
||||||
|
};
|
||||||
|
|
||||||
|
client.connect(
|
||||||
|
`${fixedScheme}${path}?${qs.encode(queryWithDefaults)}`,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
headers
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stack } = new Error();
|
||||||
|
|
||||||
|
const { promise, resolve, reject } = explodePromise<WebSocketResource>();
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
reject(new ConnectTimeoutError('Connection timed out'));
|
||||||
|
|
||||||
|
client.abort();
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
let resource: WebSocketResource | undefined;
|
||||||
|
client.on('connect', socket => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
resource = new WebSocketResource(socket, resourceOptions);
|
||||||
|
resolve(resource);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('httpResponse', async response => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
const statusCode = response.statusCode || -1;
|
||||||
|
await handleStatusCode(statusCode);
|
||||||
|
|
||||||
|
const error = new HTTPError(
|
||||||
|
'connectResource: invalid websocket response',
|
||||||
|
{
|
||||||
|
code: statusCode || -1,
|
||||||
|
headers: {},
|
||||||
|
stack,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const translatedError = translateError(error);
|
||||||
|
strictAssert(
|
||||||
|
translatedError,
|
||||||
|
'`httpResponse` event cannot be emitted with 200 status code'
|
||||||
|
);
|
||||||
|
|
||||||
|
reject(translatedError);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('connectFailed', e => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
reject(
|
||||||
|
new HTTPError('connectResource: connectFailed', {
|
||||||
|
code: -1,
|
||||||
|
headers: {},
|
||||||
|
response: e.toString(),
|
||||||
|
stack,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new AbortableProcess<WebSocketResource>(
|
||||||
|
`SocketManager.connectResource(${path})`,
|
||||||
|
{
|
||||||
|
abort() {
|
||||||
|
if (resource) {
|
||||||
|
resource.close(3000, 'aborted');
|
||||||
|
} else {
|
||||||
|
clearTimeout(timer);
|
||||||
|
client.abort();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
promise
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async checkResource(
|
||||||
|
process?: AbortableProcess<WebSocketResource>
|
||||||
|
): Promise<void> {
|
||||||
|
if (!process) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await process.getResult();
|
||||||
|
resource.forceKeepAlive();
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropAuthenticated(
|
||||||
|
process: AbortableProcess<WebSocketResource>
|
||||||
|
): void {
|
||||||
|
strictAssert(
|
||||||
|
this.authenticated === process,
|
||||||
|
'Authenticated resource mismatch'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.incomingRequestQueue = [];
|
||||||
|
this.authenticated = undefined;
|
||||||
|
this.status = SocketStatus.CLOSED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dropUnauthenticated(
|
||||||
|
process: AbortableProcess<WebSocketResource>
|
||||||
|
): void {
|
||||||
|
strictAssert(
|
||||||
|
this.unauthenticated === process,
|
||||||
|
'Unauthenticated resource mismatch'
|
||||||
|
);
|
||||||
|
this.unauthenticated = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private queueOrHandleRequest(req: IncomingWebSocketRequest): void {
|
||||||
|
if (this.requestHandlers.size === 0) {
|
||||||
|
this.incomingRequestQueue.push(req);
|
||||||
|
window.log.info(
|
||||||
|
'SocketManager: request handler unavailable, ' +
|
||||||
|
`queued request. Queue size: ${this.incomingRequestQueue.length}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const handlers of this.requestHandlers) {
|
||||||
|
try {
|
||||||
|
handlers.handleRequest(req);
|
||||||
|
} catch (error) {
|
||||||
|
window.log.warn(
|
||||||
|
'SocketManager: got exception while handling incoming request, ' +
|
||||||
|
`error: ${Errors.toLogFormat(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EventEmitter types
|
||||||
|
|
||||||
|
public on(type: 'authError', callback: (error: HTTPError) => void): this;
|
||||||
|
|
||||||
|
public on(
|
||||||
|
type: string | symbol,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
listener: (...args: Array<any>) => void
|
||||||
|
): this {
|
||||||
|
return super.on(type, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public emit(type: 'authError', error: HTTPError): boolean;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
public emit(type: string | symbol, ...args: Array<any>): boolean {
|
||||||
|
return super.emit(type, ...args);
|
||||||
|
}
|
||||||
|
}
|
5
ts/textsecure/Types.d.ts
vendored
5
ts/textsecure/Types.d.ts
vendored
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { SignalService as Proto } from '../protobuf';
|
import type { SignalService as Proto } from '../protobuf';
|
||||||
|
import type { IncomingWebSocketRequest } from './WebsocketResources';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
IdentityKeyType,
|
IdentityKeyType,
|
||||||
|
@ -232,3 +233,7 @@ export interface CallbackResultType {
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
recipients?: Record<string, Array<number>>;
|
recipients?: Record<string, Array<number>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IRequestHandler {
|
||||||
|
handleRequest(request: IncomingWebSocketRequest): void;
|
||||||
|
}
|
||||||
|
|
46
ts/textsecure/Utils.ts
Normal file
46
ts/textsecure/Utils.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export async function handleStatusCode(status: number): Promise<void> {
|
||||||
|
if (status === 499) {
|
||||||
|
window.log.error('Got 499 from Signal Server. Build is expired.');
|
||||||
|
await window.storage.put('remoteBuildExpiration', Date.now());
|
||||||
|
window.reduxActions.expiration.hydrateExpirationStatus(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function translateError(error: Error): Error | undefined {
|
||||||
|
const { code } = error;
|
||||||
|
if (code === 200) {
|
||||||
|
// Happens sometimes when we get no response. Might be nice to get 204 instead.
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let message: string;
|
||||||
|
switch (code) {
|
||||||
|
case -1:
|
||||||
|
message =
|
||||||
|
'Failed to connect to the server, please check your network connection.';
|
||||||
|
break;
|
||||||
|
case 413:
|
||||||
|
message = 'Rate limit exceeded, please try again later.';
|
||||||
|
break;
|
||||||
|
case 403:
|
||||||
|
message = 'Invalid code, please try again.';
|
||||||
|
break;
|
||||||
|
case 417:
|
||||||
|
message = 'Number already registered.';
|
||||||
|
break;
|
||||||
|
case 401:
|
||||||
|
message =
|
||||||
|
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
|
||||||
|
break;
|
||||||
|
case 404:
|
||||||
|
message = 'Number is not registered.';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
message = 'The server rejected our query, please file a bug report.';
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
error.message = `${message} (original: ${error.message})`;
|
||||||
|
return error;
|
||||||
|
}
|
|
@ -11,7 +11,7 @@
|
||||||
|
|
||||||
import fetch, { Response } from 'node-fetch';
|
import fetch, { Response } from 'node-fetch';
|
||||||
import ProxyAgent from 'proxy-agent';
|
import ProxyAgent from 'proxy-agent';
|
||||||
import { Agent, RequestOptions } from 'https';
|
import { Agent } from 'https';
|
||||||
import pProps from 'p-props';
|
import pProps from 'p-props';
|
||||||
import {
|
import {
|
||||||
compact,
|
compact,
|
||||||
|
@ -26,13 +26,13 @@ import { pki } from 'node-forge';
|
||||||
import is from '@sindresorhus/is';
|
import is from '@sindresorhus/is';
|
||||||
import PQueue from 'p-queue';
|
import PQueue from 'p-queue';
|
||||||
import { v4 as getGuid } from 'uuid';
|
import { v4 as getGuid } from 'uuid';
|
||||||
import { client as WebSocketClient, connection as WebSocket } from 'websocket';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import Long from 'long';
|
import Long from 'long';
|
||||||
|
|
||||||
import { assert } from '../util/assert';
|
import { assert } from '../util/assert';
|
||||||
import { getUserAgent } from '../util/getUserAgent';
|
import { getUserAgent } from '../util/getUserAgent';
|
||||||
import { toWebSafeBase64 } from '../util/webSafeBase64';
|
import { toWebSafeBase64 } from '../util/webSafeBase64';
|
||||||
|
import { SocketStatus } from '../types/SocketStatus';
|
||||||
import { isPackIdValid, redactPackId } from '../types/Stickers';
|
import { isPackIdValid, redactPackId } from '../types/Stickers';
|
||||||
import * as Bytes from '../Bytes';
|
import * as Bytes from '../Bytes';
|
||||||
import {
|
import {
|
||||||
|
@ -55,11 +55,14 @@ import {
|
||||||
StorageServiceCallOptionsType,
|
StorageServiceCallOptionsType,
|
||||||
StorageServiceCredentials,
|
StorageServiceCredentials,
|
||||||
} from '../textsecure.d';
|
} from '../textsecure.d';
|
||||||
|
import { SocketManager } from './SocketManager';
|
||||||
|
import WebSocketResource from './WebsocketResources';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
|
|
||||||
import { ConnectTimeoutError } from './Errors';
|
import { HTTPError } from './Errors';
|
||||||
import MessageSender from './SendMessage';
|
import MessageSender from './SendMessage';
|
||||||
import { WebAPICredentials } from './Types.d';
|
import { WebAPICredentials, IRequestHandler } from './Types.d';
|
||||||
|
import { handleStatusCode, translateError } from './Utils';
|
||||||
|
|
||||||
// TODO: remove once we move away from ArrayBuffers
|
// TODO: remove once we move away from ArrayBuffers
|
||||||
const FIXMEU8 = Uint8Array;
|
const FIXMEU8 = Uint8Array;
|
||||||
|
@ -263,96 +266,6 @@ function _validateResponse(response: any, schema: any) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConnectSocketOptions = Readonly<{
|
|
||||||
certificateAuthority: string;
|
|
||||||
proxyUrl?: string;
|
|
||||||
version: string;
|
|
||||||
timeout?: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const TEN_SECONDS = 1000 * 10;
|
|
||||||
|
|
||||||
async function _connectSocket(
|
|
||||||
url: string,
|
|
||||||
{
|
|
||||||
certificateAuthority,
|
|
||||||
proxyUrl,
|
|
||||||
version,
|
|
||||||
timeout = TEN_SECONDS,
|
|
||||||
}: ConnectSocketOptions
|
|
||||||
): Promise<WebSocket> {
|
|
||||||
let tlsOptions: RequestOptions = {
|
|
||||||
ca: certificateAuthority,
|
|
||||||
};
|
|
||||||
if (proxyUrl) {
|
|
||||||
tlsOptions = {
|
|
||||||
...tlsOptions,
|
|
||||||
agent: new ProxyAgent(proxyUrl),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'User-Agent': getUserAgent(version),
|
|
||||||
};
|
|
||||||
const client = new WebSocketClient({
|
|
||||||
tlsOptions,
|
|
||||||
maxReceivedFrameSize: 0x210000,
|
|
||||||
});
|
|
||||||
|
|
||||||
client.connect(url, undefined, undefined, headers);
|
|
||||||
|
|
||||||
const { stack } = new Error();
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
reject(new ConnectTimeoutError('Connection timed out'));
|
|
||||||
|
|
||||||
client.abort();
|
|
||||||
}, timeout);
|
|
||||||
|
|
||||||
client.on('connect', socket => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(socket);
|
|
||||||
});
|
|
||||||
|
|
||||||
client.on('httpResponse', async response => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
const statusCode = response.statusCode || -1;
|
|
||||||
await _handleStatusCode(statusCode);
|
|
||||||
|
|
||||||
const error = makeHTTPError(
|
|
||||||
'_connectSocket: invalid websocket response',
|
|
||||||
statusCode || -1,
|
|
||||||
{}, // headers
|
|
||||||
undefined,
|
|
||||||
stack
|
|
||||||
);
|
|
||||||
|
|
||||||
const translatedError = _translateError(error);
|
|
||||||
assert(
|
|
||||||
translatedError,
|
|
||||||
'`httpResponse` event cannot be emitted with 200 status code'
|
|
||||||
);
|
|
||||||
|
|
||||||
reject(translatedError);
|
|
||||||
});
|
|
||||||
client.on('connectFailed', e => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
reject(
|
|
||||||
makeHTTPError(
|
|
||||||
'_connectSocket: connectFailed',
|
|
||||||
-1,
|
|
||||||
{},
|
|
||||||
e.toString(),
|
|
||||||
stack
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const FIVE_MINUTES = 1000 * 60 * 5;
|
const FIVE_MINUTES = 1000 * 60 * 5;
|
||||||
|
|
||||||
type AgentCacheType = {
|
type AgentCacheType = {
|
||||||
|
@ -378,6 +291,7 @@ type HTTPCodeType = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
type RedactUrl = (url: string) => string;
|
type RedactUrl = (url: string) => string;
|
||||||
|
|
||||||
type PromiseAjaxOptionsType = {
|
type PromiseAjaxOptionsType = {
|
||||||
|
socketManager?: SocketManager;
|
||||||
accessKey?: string;
|
accessKey?: string;
|
||||||
basicAuth?: string;
|
basicAuth?: string;
|
||||||
certificateAuthority?: string;
|
certificateAuthority?: string;
|
||||||
|
@ -468,76 +382,28 @@ function getHostname(url: string): string {
|
||||||
return urlObject.hostname;
|
return urlObject.hostname;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _handleStatusCode(
|
|
||||||
status: number,
|
|
||||||
unauthenticated = false
|
|
||||||
): Promise<void> {
|
|
||||||
if (status === 499) {
|
|
||||||
window.log.error('Got 499 from Signal Server. Build is expired.');
|
|
||||||
await window.storage.put('remoteBuildExpiration', Date.now());
|
|
||||||
window.reduxActions.expiration.hydrateExpirationStatus(true);
|
|
||||||
}
|
|
||||||
if (!unauthenticated && status === 401) {
|
|
||||||
window.log.error('Got 401 from Signal Server. We might be unlinked.');
|
|
||||||
window.Whisper.events.trigger('mightBeUnlinked');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function _translateError(error: Error): Error | undefined {
|
|
||||||
const { code } = error;
|
|
||||||
if (code === 200) {
|
|
||||||
// Happens sometimes when we get no response. Might be nice to get 204 instead.
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
let message: string;
|
|
||||||
switch (code) {
|
|
||||||
case -1:
|
|
||||||
message =
|
|
||||||
'Failed to connect to the server, please check your network connection.';
|
|
||||||
break;
|
|
||||||
case 413:
|
|
||||||
message = 'Rate limit exceeded, please try again later.';
|
|
||||||
break;
|
|
||||||
case 403:
|
|
||||||
message = 'Invalid code, please try again.';
|
|
||||||
break;
|
|
||||||
case 417:
|
|
||||||
message = 'Number already registered.';
|
|
||||||
break;
|
|
||||||
case 401:
|
|
||||||
message =
|
|
||||||
'Invalid authentication, most likely someone re-registered and invalidated our registration.';
|
|
||||||
break;
|
|
||||||
case 404:
|
|
||||||
message = 'Number is not registered.';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
message = 'The server rejected our query, please file a bug report.';
|
|
||||||
}
|
|
||||||
error.message = `${message} (original: ${error.message})`;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function _promiseAjax(
|
async function _promiseAjax(
|
||||||
providedUrl: string | null,
|
providedUrl: string | null,
|
||||||
options: PromiseAjaxOptionsType
|
options: PromiseAjaxOptionsType
|
||||||
): Promise<any> {
|
): Promise<
|
||||||
return new Promise((resolve, reject) => {
|
| string
|
||||||
|
| ArrayBuffer
|
||||||
|
| unknown
|
||||||
|
| JSONWithDetailsType
|
||||||
|
| ArrayBufferWithDetailsType
|
||||||
|
> {
|
||||||
const url = providedUrl || `${options.host}/${options.path}`;
|
const url = providedUrl || `${options.host}/${options.path}`;
|
||||||
|
|
||||||
const unauthLabel = options.unauthenticated ? ' (unauth)' : '';
|
const unauthLabel = options.unauthenticated ? ' (unauth)' : '';
|
||||||
if (options.redactUrl) {
|
if (options.redactUrl) {
|
||||||
window.log.info(
|
window.log.info(`${options.type} ${options.redactUrl(url)}${unauthLabel}`);
|
||||||
`${options.type} ${options.redactUrl(url)}${unauthLabel}`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
window.log.info(`${options.type} ${url}${unauthLabel}`);
|
window.log.info(`${options.type} ${url}${unauthLabel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout =
|
const timeout = typeof options.timeout === 'number' ? options.timeout : 10000;
|
||||||
typeof options.timeout === 'number' ? options.timeout : 10000;
|
|
||||||
|
|
||||||
const { proxyUrl } = options;
|
const { proxyUrl, socketManager } = options;
|
||||||
const agentType = options.unauthenticated ? 'unauth' : 'auth';
|
const agentType = options.unauthenticated ? 'unauth' : 'auth';
|
||||||
const cacheKey = `${proxyUrl}-${agentType}`;
|
const cacheKey = `${proxyUrl}-${agentType}`;
|
||||||
|
|
||||||
|
@ -600,18 +466,27 @@ async function _promiseAjax(
|
||||||
fetchOptions.headers['Content-Type'] = options.contentType;
|
fetchOptions.headers['Content-Type'] = options.contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(url, fetchOptions)
|
let response: Response;
|
||||||
.then(async response => {
|
let result: string | ArrayBuffer | unknown;
|
||||||
|
try {
|
||||||
|
response = socketManager
|
||||||
|
? await socketManager.fetch(url, fetchOptions)
|
||||||
|
: await fetch(url, fetchOptions);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
options.serverUrl &&
|
options.serverUrl &&
|
||||||
getHostname(options.serverUrl) === getHostname(url)
|
getHostname(options.serverUrl) === getHostname(url)
|
||||||
) {
|
) {
|
||||||
await _handleStatusCode(response.status, unauthenticated);
|
await handleStatusCode(response.status);
|
||||||
|
|
||||||
|
if (!unauthenticated && response.status === 401) {
|
||||||
|
window.log.error('Got 401 from Signal Server. We might be unlinked.');
|
||||||
|
window.Whisper.events.trigger('mightBeUnlinked');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let resultPromise;
|
|
||||||
if (DEBUG && !isSuccess(response.status)) {
|
if (DEBUG && !isSuccess(response.status)) {
|
||||||
resultPromise = response.text();
|
result = await response.text();
|
||||||
} else if (
|
} else if (
|
||||||
(options.responseType === 'json' ||
|
(options.responseType === 'json' ||
|
||||||
options.responseType === 'jsonwithdetails') &&
|
options.responseType === 'jsonwithdetails') &&
|
||||||
|
@ -619,27 +494,46 @@ async function _promiseAjax(
|
||||||
response.headers.get('Content-Type') || ''
|
response.headers.get('Content-Type') || ''
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
resultPromise = response.json();
|
result = await response.json();
|
||||||
} else if (
|
} else if (
|
||||||
options.responseType === 'arraybuffer' ||
|
options.responseType === 'arraybuffer' ||
|
||||||
options.responseType === 'arraybufferwithdetails'
|
options.responseType === 'arraybufferwithdetails'
|
||||||
) {
|
) {
|
||||||
resultPromise = response.buffer();
|
result = await response.arrayBuffer();
|
||||||
} else {
|
} else {
|
||||||
resultPromise = response.textConverted();
|
result = await response.textConverted();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (options.redactUrl) {
|
||||||
|
window.log.error(options.type, options.redactUrl(url), 0, 'Error');
|
||||||
|
} else {
|
||||||
|
window.log.error(options.type, url, 0, 'Error');
|
||||||
|
}
|
||||||
|
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
||||||
|
throw makeHTTPError('promiseAjax catch', 0, {}, e.toString(), stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resultPromise.then(result => {
|
if (!isSuccess(response.status)) {
|
||||||
if (isSuccess(response.status)) {
|
if (options.redactUrl) {
|
||||||
if (
|
window.log.info(
|
||||||
options.responseType === 'arraybuffer' ||
|
options.type,
|
||||||
options.responseType === 'arraybufferwithdetails'
|
options.redactUrl(url),
|
||||||
) {
|
response.status,
|
||||||
result = result.buffer.slice(
|
'Error'
|
||||||
result.byteOffset,
|
);
|
||||||
result.byteOffset + result.byteLength
|
} else {
|
||||||
|
window.log.error(options.type, url, response.status, 'Error');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw makeHTTPError(
|
||||||
|
'promiseAjax: error response',
|
||||||
|
response.status,
|
||||||
|
response.headers.raw(),
|
||||||
|
result,
|
||||||
|
options.stack
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
options.responseType === 'json' ||
|
options.responseType === 'json' ||
|
||||||
options.responseType === 'jsonwithdetails'
|
options.responseType === 'jsonwithdetails'
|
||||||
|
@ -654,24 +548,15 @@ async function _promiseAjax(
|
||||||
'Error'
|
'Error'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
window.log.error(
|
window.log.error(options.type, url, response.status, 'Error');
|
||||||
options.type,
|
|
||||||
url,
|
|
||||||
response.status,
|
|
||||||
'Error'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
reject(
|
throw makeHTTPError(
|
||||||
makeHTTPError(
|
|
||||||
'promiseAjax: invalid response',
|
'promiseAjax: invalid response',
|
||||||
response.status,
|
response.status,
|
||||||
response.headers.raw(),
|
response.headers.raw(),
|
||||||
result,
|
result,
|
||||||
options.stack
|
options.stack
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -686,17 +571,18 @@ async function _promiseAjax(
|
||||||
} else {
|
} else {
|
||||||
window.log.info(options.type, url, response.status, 'Success');
|
window.log.info(options.type, url, response.status, 'Success');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.responseType === 'arraybufferwithdetails') {
|
if (options.responseType === 'arraybufferwithdetails') {
|
||||||
|
assert(result instanceof ArrayBuffer, 'Expected ArrayBuffer result');
|
||||||
const fullResult: ArrayBufferWithDetailsType = {
|
const fullResult: ArrayBufferWithDetailsType = {
|
||||||
data: result,
|
data: result,
|
||||||
contentType: getContentType(response),
|
contentType: getContentType(response),
|
||||||
response,
|
response,
|
||||||
};
|
};
|
||||||
|
|
||||||
resolve(fullResult);
|
return fullResult;
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.responseType === 'jsonwithdetails') {
|
if (options.responseType === 'jsonwithdetails') {
|
||||||
const fullResult: JSONWithDetailsType = {
|
const fullResult: JSONWithDetailsType = {
|
||||||
data: result,
|
data: result,
|
||||||
|
@ -704,48 +590,10 @@ async function _promiseAjax(
|
||||||
response,
|
response,
|
||||||
};
|
};
|
||||||
|
|
||||||
resolve(fullResult);
|
return fullResult;
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(result);
|
return result;
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.redactUrl) {
|
|
||||||
window.log.info(
|
|
||||||
options.type,
|
|
||||||
options.redactUrl(url),
|
|
||||||
response.status,
|
|
||||||
'Error'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
window.log.error(options.type, url, response.status, 'Error');
|
|
||||||
}
|
|
||||||
|
|
||||||
reject(
|
|
||||||
makeHTTPError(
|
|
||||||
'promiseAjax: error response',
|
|
||||||
response.status,
|
|
||||||
response.headers.raw(),
|
|
||||||
result,
|
|
||||||
options.stack
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
if (options.redactUrl) {
|
|
||||||
window.log.error(options.type, options.redactUrl(url), 0, 'Error');
|
|
||||||
} else {
|
|
||||||
window.log.error(options.type, url, 0, 'Error');
|
|
||||||
}
|
|
||||||
const stack = `${e.stack}\nInitial stack:\n${options.stack}`;
|
|
||||||
reject(makeHTTPError('promiseAjax catch', 0, {}, e.toString(), stack));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _retryAjax(
|
async function _retryAjax(
|
||||||
|
@ -793,21 +641,12 @@ function makeHTTPError(
|
||||||
response: any,
|
response: any,
|
||||||
stack?: string
|
stack?: string
|
||||||
) {
|
) {
|
||||||
const code = providedCode > 999 || providedCode < 100 ? -1 : providedCode;
|
return new HTTPError(message, {
|
||||||
const e = new Error(`${message}; code: ${code}`);
|
code: providedCode,
|
||||||
e.name = 'HTTPError';
|
headers,
|
||||||
e.code = code;
|
response,
|
||||||
e.responseHeaders = headers;
|
stack,
|
||||||
if (DEBUG && response) {
|
});
|
||||||
e.stack += `\nresponse: ${response}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.stack += `\nOriginal stack:\n${stack}`;
|
|
||||||
if (response) {
|
|
||||||
e.response = response;
|
|
||||||
}
|
|
||||||
|
|
||||||
return e;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const URL_CALLS = {
|
const URL_CALLS = {
|
||||||
|
@ -844,6 +683,21 @@ const URL_CALLS = {
|
||||||
challenge: 'v1/challenge',
|
challenge: 'v1/challenge',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WEBSOCKET_CALLS = new Set<keyof typeof URL_CALLS>([
|
||||||
|
// MessageController
|
||||||
|
'messages',
|
||||||
|
'reportMessage',
|
||||||
|
|
||||||
|
// ProfileController
|
||||||
|
'profile',
|
||||||
|
|
||||||
|
// AttachmentControllerV2
|
||||||
|
'attachmentId',
|
||||||
|
|
||||||
|
// RemoteConfigController
|
||||||
|
'config',
|
||||||
|
]);
|
||||||
|
|
||||||
type InitializeOptionsType = {
|
type InitializeOptionsType = {
|
||||||
url: string;
|
url: string;
|
||||||
storageUrl: string;
|
storageUrl: string;
|
||||||
|
@ -983,7 +837,6 @@ export type WebAPIType = {
|
||||||
deviceId?: number,
|
deviceId?: number,
|
||||||
options?: { accessKey?: string }
|
options?: { accessKey?: string }
|
||||||
) => Promise<ServerKeysType>;
|
) => Promise<ServerKeysType>;
|
||||||
getMessageSocket: () => Promise<WebSocket>;
|
|
||||||
getMyKeys: () => Promise<number>;
|
getMyKeys: () => Promise<number>;
|
||||||
getProfile: (
|
getProfile: (
|
||||||
identifier: string,
|
identifier: string,
|
||||||
|
@ -1000,7 +853,9 @@ export type WebAPIType = {
|
||||||
profileKeyCredentialRequest?: string;
|
profileKeyCredentialRequest?: string;
|
||||||
}
|
}
|
||||||
) => Promise<any>;
|
) => Promise<any>;
|
||||||
getProvisioningSocket: () => Promise<WebSocket>;
|
getProvisioningResource: (
|
||||||
|
handler: IRequestHandler
|
||||||
|
) => Promise<WebSocketResource>;
|
||||||
getSenderCertificate: (
|
getSenderCertificate: (
|
||||||
withUuid?: boolean
|
withUuid?: boolean
|
||||||
) => Promise<{ certificate: string }>;
|
) => Promise<{ certificate: string }>;
|
||||||
|
@ -1086,6 +941,12 @@ export type WebAPIType = {
|
||||||
Array<{ name: string; enabled: boolean; value: string | null }>
|
Array<{ name: string; enabled: boolean; value: string | null }>
|
||||||
>;
|
>;
|
||||||
authenticate: (credentials: WebAPICredentials) => Promise<void>;
|
authenticate: (credentials: WebAPICredentials) => Promise<void>;
|
||||||
|
getSocketStatus: () => SocketStatus;
|
||||||
|
registerRequestHandler: (handler: IRequestHandler) => void;
|
||||||
|
unregisterRequestHandler: (handler: IRequestHandler) => void;
|
||||||
|
checkSockets: () => void;
|
||||||
|
onOnline: () => Promise<void>;
|
||||||
|
onOffline: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SignedPreKeyType = {
|
export type SignedPreKeyType = {
|
||||||
|
@ -1200,8 +1061,27 @@ export function initialize({
|
||||||
const PARSE_RANGE_HEADER = /\/(\d+)$/;
|
const PARSE_RANGE_HEADER = /\/(\d+)$/;
|
||||||
const PARSE_GROUP_LOG_RANGE_HEADER = /$versions (\d{1,10})-(\d{1,10})\/(d{1,10})/;
|
const PARSE_GROUP_LOG_RANGE_HEADER = /$versions (\d{1,10})-(\d{1,10})\/(d{1,10})/;
|
||||||
|
|
||||||
|
const socketManager = new SocketManager({
|
||||||
|
url,
|
||||||
|
certificateAuthority,
|
||||||
|
version,
|
||||||
|
proxyUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
socketManager.on('authError', () => {
|
||||||
|
window.Whisper.events.trigger('unlinkAndDisconnect');
|
||||||
|
});
|
||||||
|
|
||||||
|
socketManager.authenticate({ username, password });
|
||||||
|
|
||||||
// Thanks, function hoisting!
|
// Thanks, function hoisting!
|
||||||
return {
|
return {
|
||||||
|
getSocketStatus,
|
||||||
|
checkSockets,
|
||||||
|
onOnline,
|
||||||
|
onOffline,
|
||||||
|
registerRequestHandler,
|
||||||
|
unregisterRequestHandler,
|
||||||
authenticate,
|
authenticate,
|
||||||
confirmCode,
|
confirmCode,
|
||||||
createGroup,
|
createGroup,
|
||||||
|
@ -1220,11 +1100,10 @@ export function initialize({
|
||||||
getIceServers,
|
getIceServers,
|
||||||
getKeysForIdentifier,
|
getKeysForIdentifier,
|
||||||
getKeysForIdentifierUnauth,
|
getKeysForIdentifierUnauth,
|
||||||
getMessageSocket,
|
|
||||||
getMyKeys,
|
getMyKeys,
|
||||||
getProfile,
|
getProfile,
|
||||||
getProfileUnauth,
|
getProfileUnauth,
|
||||||
getProvisioningSocket,
|
getProvisioningResource,
|
||||||
getSenderCertificate,
|
getSenderCertificate,
|
||||||
getSticker,
|
getSticker,
|
||||||
getStickerPackManifest,
|
getStickerPackManifest,
|
||||||
|
@ -1261,7 +1140,10 @@ export function initialize({
|
||||||
param.urlParameters = '';
|
param.urlParameters = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const useWebSocket = WEBSOCKET_CALLS.has(param.call);
|
||||||
|
|
||||||
return _outerAjax(null, {
|
return _outerAjax(null, {
|
||||||
|
socketManager: useWebSocket ? socketManager : undefined,
|
||||||
basicAuth: param.basicAuth,
|
basicAuth: param.basicAuth,
|
||||||
certificateAuthority,
|
certificateAuthority,
|
||||||
contentType: param.contentType || 'application/json; charset=utf-8',
|
contentType: param.contentType || 'application/json; charset=utf-8',
|
||||||
|
@ -1282,7 +1164,7 @@ export function initialize({
|
||||||
unauthenticated: param.unauthenticated,
|
unauthenticated: param.unauthenticated,
|
||||||
accessKey: param.accessKey,
|
accessKey: param.accessKey,
|
||||||
}).catch((e: Error) => {
|
}).catch((e: Error) => {
|
||||||
const translatedError = _translateError(e);
|
const translatedError = translateError(e);
|
||||||
if (translatedError) {
|
if (translatedError) {
|
||||||
throw translatedError;
|
throw translatedError;
|
||||||
}
|
}
|
||||||
|
@ -1311,6 +1193,33 @@ export function initialize({
|
||||||
}: WebAPICredentials) {
|
}: WebAPICredentials) {
|
||||||
username = newUsername;
|
username = newUsername;
|
||||||
password = newPassword;
|
password = newPassword;
|
||||||
|
|
||||||
|
await socketManager.authenticate({ username, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSocketStatus(): SocketStatus {
|
||||||
|
return socketManager.getStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSockets(): void {
|
||||||
|
// Intentionally not awaiting
|
||||||
|
socketManager.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOnline(): Promise<void> {
|
||||||
|
await socketManager.onOnline();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOffline(): Promise<void> {
|
||||||
|
await socketManager.onOffline();
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerRequestHandler(handler: IRequestHandler): void {
|
||||||
|
socketManager.registerRequestHandler(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unregisterRequestHandler(handler: IRequestHandler): void {
|
||||||
|
socketManager.unregisterRequestHandler(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getConfig() {
|
async function getConfig() {
|
||||||
|
@ -1589,8 +1498,7 @@ export function initialize({
|
||||||
const urlPrefix = deviceName ? '/' : '/code/';
|
const urlPrefix = deviceName ? '/' : '/code/';
|
||||||
|
|
||||||
// We update our saved username and password, since we're creating a new account
|
// We update our saved username and password, since we're creating a new account
|
||||||
username = number;
|
await authenticate({ username: number, password: newPassword });
|
||||||
password = newPassword;
|
|
||||||
|
|
||||||
const response = await _ajax({
|
const response = await _ajax({
|
||||||
call,
|
call,
|
||||||
|
@ -1601,7 +1509,10 @@ export function initialize({
|
||||||
});
|
});
|
||||||
|
|
||||||
// From here on out, our username will be our UUID or E164 combined with device
|
// From here on out, our username will be our UUID or E164 combined with device
|
||||||
username = `${response.uuid || number}.${response.deviceId || 1}`;
|
await authenticate({
|
||||||
|
username: `${response.uuid || number}.${response.deviceId || 1}`,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -2157,7 +2068,7 @@ export function initialize({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
type,
|
type,
|
||||||
version,
|
version,
|
||||||
});
|
}) as Promise<ArrayBufferWithDetailsType>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Groups
|
// Groups
|
||||||
|
@ -2312,7 +2223,7 @@ export function initialize({
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
type: 'GET',
|
type: 'GET',
|
||||||
version,
|
version,
|
||||||
});
|
}) as Promise<ArrayBuffer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createGroup(
|
async function createGroup(
|
||||||
|
@ -2461,32 +2372,10 @@ export function initialize({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMessageSocket(): Promise<WebSocket> {
|
function getProvisioningResource(
|
||||||
window.log.info('opening message socket', url);
|
handler: IRequestHandler
|
||||||
const fixedScheme = url
|
): Promise<WebSocketResource> {
|
||||||
.replace('https://', 'wss://')
|
return socketManager.getProvisioningResource(handler);
|
||||||
.replace('http://', 'ws://');
|
|
||||||
const login = encodeURIComponent(username);
|
|
||||||
const pass = encodeURIComponent(password);
|
|
||||||
const clientVersion = encodeURIComponent(version);
|
|
||||||
|
|
||||||
return _connectSocket(
|
|
||||||
`${fixedScheme}/v1/websocket/?login=${login}&password=${pass}&agent=OWD&version=${clientVersion}`,
|
|
||||||
{ certificateAuthority, proxyUrl, version }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProvisioningSocket(): Promise<WebSocket> {
|
|
||||||
window.log.info('opening provisioning socket', url);
|
|
||||||
const fixedScheme = url
|
|
||||||
.replace('https://', 'wss://')
|
|
||||||
.replace('http://', 'ws://');
|
|
||||||
const clientVersion = encodeURIComponent(version);
|
|
||||||
|
|
||||||
return _connectSocket(
|
|
||||||
`${fixedScheme}/v1/websocket/provisioning/?agent=OWD&version=${clientVersion}`,
|
|
||||||
{ certificateAuthority, proxyUrl, version }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDirectoryAuth(): Promise<{
|
async function getDirectoryAuth(): Promise<{
|
||||||
|
@ -2688,7 +2577,7 @@ export function initialize({
|
||||||
const pubKeyBase64 = arrayBufferToBase64(slicedPubKey);
|
const pubKeyBase64 = arrayBufferToBase64(slicedPubKey);
|
||||||
// Do request
|
// Do request
|
||||||
const data = JSON.stringify({ clientPublic: pubKeyBase64 });
|
const data = JSON.stringify({ clientPublic: pubKeyBase64 });
|
||||||
const result: JSONWithDetailsType = await _outerAjax(null, {
|
const result: JSONWithDetailsType = (await _outerAjax(null, {
|
||||||
certificateAuthority,
|
certificateAuthority,
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
contentType: 'application/json; charset=utf-8',
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
@ -2700,7 +2589,7 @@ export function initialize({
|
||||||
data,
|
data,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
version,
|
version,
|
||||||
});
|
})) as JSONWithDetailsType;
|
||||||
|
|
||||||
const { data: responseBody, response } = result;
|
const { data: responseBody, response } = result;
|
||||||
|
|
||||||
|
@ -2806,7 +2695,7 @@ export function initialize({
|
||||||
iv: string;
|
iv: string;
|
||||||
data: string;
|
data: string;
|
||||||
mac: string;
|
mac: string;
|
||||||
} = await _outerAjax(null, {
|
} = (await _outerAjax(null, {
|
||||||
certificateAuthority,
|
certificateAuthority,
|
||||||
type: 'PUT',
|
type: 'PUT',
|
||||||
headers: cookie
|
headers: cookie
|
||||||
|
@ -2823,7 +2712,7 @@ export function initialize({
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
data: JSON.stringify(data),
|
data: JSON.stringify(data),
|
||||||
version,
|
version,
|
||||||
});
|
})) as any;
|
||||||
|
|
||||||
// Decode discovery request response
|
// Decode discovery request response
|
||||||
const decodedDiscoveryResponse: {
|
const decodedDiscoveryResponse: {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
/* eslint-disable max-classes-per-file */
|
/* eslint-disable max-classes-per-file, no-restricted-syntax */
|
||||||
/*
|
/*
|
||||||
* WebSocket-Resources
|
* WebSocket-Resources
|
||||||
*
|
*
|
||||||
|
@ -12,12 +12,11 @@
|
||||||
* request.respond(200, 'OK');
|
* request.respond(200, 'OK');
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* client.sendRequest({
|
* const { response, status } = await client.sendRequest({
|
||||||
* verb: 'PUT',
|
* verb: 'PUT',
|
||||||
* path: '/v1/messages',
|
* path: '/v1/messages',
|
||||||
* body: '{ some: "json" }',
|
* headers: ['content-type:application/json'],
|
||||||
* success: function(message, status, request) {...},
|
* body: Buffer.from('{ some: "json" }'),
|
||||||
* error: function(message, status, request) {...}
|
|
||||||
* });
|
* });
|
||||||
*
|
*
|
||||||
* 1. https://github.com/signalapp/WebSocket-Resources
|
* 1. https://github.com/signalapp/WebSocket-Resources
|
||||||
|
@ -32,13 +31,10 @@ import { dropNull } from '../util/dropNull';
|
||||||
import { isOlderThan } from '../util/timestamp';
|
import { isOlderThan } from '../util/timestamp';
|
||||||
import { strictAssert } from '../util/assert';
|
import { strictAssert } from '../util/assert';
|
||||||
import { normalizeNumber } from '../util/normalizeNumber';
|
import { normalizeNumber } from '../util/normalizeNumber';
|
||||||
|
import * as Errors from '../types/errors';
|
||||||
import { SignalService as Proto } from '../protobuf';
|
import { SignalService as Proto } from '../protobuf';
|
||||||
|
|
||||||
type Callback = (
|
const THIRTY_SECONDS = 30 * 1000;
|
||||||
message: string,
|
|
||||||
status: number,
|
|
||||||
request: OutgoingWebSocketRequest
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
export class IncomingWebSocketRequest {
|
export class IncomingWebSocketRequest {
|
||||||
private readonly id: Long | number;
|
private readonly id: Long | number;
|
||||||
|
@ -53,7 +49,7 @@ export class IncomingWebSocketRequest {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
request: Proto.IWebSocketRequestMessage,
|
request: Proto.IWebSocketRequestMessage,
|
||||||
private readonly socket: WebSocket
|
private readonly sendBytes: (bytes: Buffer) => void
|
||||||
) {
|
) {
|
||||||
strictAssert(request.id, 'request without id');
|
strictAssert(request.id, 'request without id');
|
||||||
strictAssert(request.verb, 'request without verb');
|
strictAssert(request.verb, 'request without verb');
|
||||||
|
@ -64,7 +60,6 @@ export class IncomingWebSocketRequest {
|
||||||
this.path = request.path;
|
this.path = request.path;
|
||||||
this.body = dropNull(request.body);
|
this.body = dropNull(request.body);
|
||||||
this.headers = request.headers || [];
|
this.headers = request.headers || [];
|
||||||
this.socket = socket;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public respond(status: number, message: string): void {
|
public respond(status: number, message: string): void {
|
||||||
|
@ -73,47 +68,24 @@ export class IncomingWebSocketRequest {
|
||||||
response: { id: this.id, message, status },
|
response: { id: this.id, message, status },
|
||||||
}).finish();
|
}).finish();
|
||||||
|
|
||||||
this.socket.sendBytes(Buffer.from(bytes));
|
this.sendBytes(Buffer.from(bytes));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type OutgoingWebSocketRequestOptions = Readonly<{
|
export type SendRequestOptions = Readonly<{
|
||||||
verb: string;
|
verb: string;
|
||||||
path: string;
|
path: string;
|
||||||
body?: Uint8Array;
|
body?: Uint8Array;
|
||||||
|
timeout?: number;
|
||||||
headers?: ReadonlyArray<string>;
|
headers?: ReadonlyArray<string>;
|
||||||
error?: Callback;
|
|
||||||
success?: Callback;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export class OutgoingWebSocketRequest {
|
export type SendRequestResult = Readonly<{
|
||||||
public readonly error: Callback | undefined;
|
status: number;
|
||||||
|
message: string;
|
||||||
public readonly success: Callback | undefined;
|
response?: Uint8Array;
|
||||||
|
headers: ReadonlyArray<string>;
|
||||||
public response: Proto.IWebSocketResponseMessage | undefined;
|
}>;
|
||||||
|
|
||||||
constructor(
|
|
||||||
id: number,
|
|
||||||
options: OutgoingWebSocketRequestOptions,
|
|
||||||
socket: WebSocket
|
|
||||||
) {
|
|
||||||
this.error = options.error;
|
|
||||||
this.success = options.success;
|
|
||||||
|
|
||||||
const bytes = Proto.WebSocketMessage.encode({
|
|
||||||
type: Proto.WebSocketMessage.Type.REQUEST,
|
|
||||||
request: {
|
|
||||||
verb: options.verb,
|
|
||||||
path: options.path,
|
|
||||||
body: options.body,
|
|
||||||
headers: options.headers ? options.headers.slice() : undefined,
|
|
||||||
id,
|
|
||||||
},
|
|
||||||
}).finish();
|
|
||||||
socket.sendBytes(Buffer.from(bytes));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WebSocketResourceOptions = {
|
export type WebSocketResourceOptions = {
|
||||||
handleRequest?: (request: IncomingWebSocketRequest) => void;
|
handleRequest?: (request: IncomingWebSocketRequest) => void;
|
||||||
|
@ -129,12 +101,21 @@ export class CloseEvent extends Event {
|
||||||
export default class WebSocketResource extends EventTarget {
|
export default class WebSocketResource extends EventTarget {
|
||||||
private outgoingId = 1;
|
private outgoingId = 1;
|
||||||
|
|
||||||
private closed?: boolean;
|
private closed = false;
|
||||||
|
|
||||||
private readonly outgoingMap = new Map<number, OutgoingWebSocketRequest>();
|
private readonly outgoingMap = new Map<
|
||||||
|
number,
|
||||||
|
(result: SendRequestResult) => void
|
||||||
|
>();
|
||||||
|
|
||||||
private readonly boundOnMessage: (message: IMessage) => void;
|
private readonly boundOnMessage: (message: IMessage) => void;
|
||||||
|
|
||||||
|
private activeRequests = new Set<IncomingWebSocketRequest | number>();
|
||||||
|
|
||||||
|
private shuttingDown = false;
|
||||||
|
|
||||||
|
private shutdownTimer?: NodeJS.Timeout;
|
||||||
|
|
||||||
// Public for tests
|
// Public for tests
|
||||||
public readonly keepalive?: KeepAlive;
|
public readonly keepalive?: KeepAlive;
|
||||||
|
|
||||||
|
@ -158,11 +139,22 @@ export default class WebSocketResource extends EventTarget {
|
||||||
keepalive.reset();
|
keepalive.reset();
|
||||||
socket.on('message', () => keepalive.reset());
|
socket.on('message', () => keepalive.reset());
|
||||||
socket.on('close', () => keepalive.stop());
|
socket.on('close', () => keepalive.stop());
|
||||||
|
socket.on('error', (error: Error) => {
|
||||||
|
window.log.warn(
|
||||||
|
'WebSocketResource: WebSocket error',
|
||||||
|
Errors.toLogFormat(error)
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('close', () => {
|
socket.on('close', (code, reason) => {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
|
|
||||||
|
window.log.warn('WebSocketResource: Socket closed');
|
||||||
|
this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.addEventListener('close', () => this.onClose());
|
||||||
}
|
}
|
||||||
|
|
||||||
public addEventListener(
|
public addEventListener(
|
||||||
|
@ -174,19 +166,50 @@ export default class WebSocketResource extends EventTarget {
|
||||||
return super.addEventListener(name, handler);
|
return super.addEventListener(name, handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
public sendRequest(
|
public async sendRequest(
|
||||||
options: OutgoingWebSocketRequestOptions
|
options: SendRequestOptions
|
||||||
): OutgoingWebSocketRequest {
|
): Promise<SendRequestResult> {
|
||||||
const id = this.outgoingId;
|
const id = this.outgoingId;
|
||||||
strictAssert(!this.outgoingMap.has(id), 'Duplicate outgoing request');
|
strictAssert(!this.outgoingMap.has(id), 'Duplicate outgoing request');
|
||||||
|
|
||||||
// eslint-disable-next-line no-bitwise
|
// eslint-disable-next-line no-bitwise
|
||||||
this.outgoingId = Math.max(1, (this.outgoingId + 1) & 0x7fffffff);
|
this.outgoingId = Math.max(1, (this.outgoingId + 1) & 0x7fffffff);
|
||||||
|
|
||||||
const outgoing = new OutgoingWebSocketRequest(id, options, this.socket);
|
const bytes = Proto.WebSocketMessage.encode({
|
||||||
this.outgoingMap.set(id, outgoing);
|
type: Proto.WebSocketMessage.Type.REQUEST,
|
||||||
|
request: {
|
||||||
|
verb: options.verb,
|
||||||
|
path: options.path,
|
||||||
|
body: options.body,
|
||||||
|
headers: options.headers ? options.headers.slice() : undefined,
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
}).finish();
|
||||||
|
|
||||||
return outgoing;
|
strictAssert(!this.shuttingDown, 'Cannot send request, shutting down');
|
||||||
|
this.addActive(id);
|
||||||
|
const promise = new Promise<SendRequestResult>((resolve, reject) => {
|
||||||
|
let timer = options.timeout
|
||||||
|
? setTimeout(() => {
|
||||||
|
this.removeActive(id);
|
||||||
|
reject(new Error('Request timed out'));
|
||||||
|
}, options.timeout)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
this.outgoingMap.set(id, result => {
|
||||||
|
if (timer !== undefined) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.removeActive(id);
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.sendBytes(Buffer.from(bytes));
|
||||||
|
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
public forceKeepAlive(): void {
|
public forceKeepAlive(): void {
|
||||||
|
@ -218,11 +241,37 @@ export default class WebSocketResource extends EventTarget {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.log.warn('Dispatching our own socket close event');
|
window.log.warn(
|
||||||
|
'WebSocketResource: Dispatching our own socket close event'
|
||||||
|
);
|
||||||
this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
|
this.dispatchEvent(new CloseEvent(code, reason || 'normal'));
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public shutdown(): void {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.activeRequests.size === 0) {
|
||||||
|
window.log.info('WebSocketResource: no active requests, closing');
|
||||||
|
this.close(3000, 'Shutdown');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.shuttingDown = true;
|
||||||
|
|
||||||
|
window.log.info('WebSocketResource: shutting down');
|
||||||
|
this.shutdownTimer = setTimeout(() => {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.warn('WebSocketResource: Failed to shutdown gracefully');
|
||||||
|
this.close(3000, 'Shutdown');
|
||||||
|
}, THIRTY_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
private onMessage({ type, binaryData }: IMessage): void {
|
private onMessage({ type, binaryData }: IMessage): void {
|
||||||
if (type !== 'binary' || !binaryData) {
|
if (type !== 'binary' || !binaryData) {
|
||||||
throw new Error(`Unsupported websocket message type: ${type}`);
|
throw new Error(`Unsupported websocket message type: ${type}`);
|
||||||
|
@ -236,7 +285,23 @@ export default class WebSocketResource extends EventTarget {
|
||||||
const handleRequest =
|
const handleRequest =
|
||||||
this.options.handleRequest ||
|
this.options.handleRequest ||
|
||||||
(request => request.respond(404, 'Not found'));
|
(request => request.respond(404, 'Not found'));
|
||||||
handleRequest(new IncomingWebSocketRequest(message.request, this.socket));
|
|
||||||
|
const incomingRequest = new IncomingWebSocketRequest(
|
||||||
|
message.request,
|
||||||
|
(bytes: Buffer): void => {
|
||||||
|
this.removeActive(incomingRequest);
|
||||||
|
|
||||||
|
this.socket.sendBytes(bytes);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.shuttingDown) {
|
||||||
|
incomingRequest.respond(500, 'Shutting down');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addActive(incomingRequest);
|
||||||
|
handleRequest(incomingRequest);
|
||||||
} else if (
|
} else if (
|
||||||
message.type === Proto.WebSocketMessage.Type.RESPONSE &&
|
message.type === Proto.WebSocketMessage.Type.RESPONSE &&
|
||||||
message.response
|
message.response
|
||||||
|
@ -245,26 +310,61 @@ export default class WebSocketResource extends EventTarget {
|
||||||
strictAssert(response.id, 'response without id');
|
strictAssert(response.id, 'response without id');
|
||||||
|
|
||||||
const responseId = normalizeNumber(response.id);
|
const responseId = normalizeNumber(response.id);
|
||||||
const request = this.outgoingMap.get(responseId);
|
const resolve = this.outgoingMap.get(responseId);
|
||||||
this.outgoingMap.delete(responseId);
|
this.outgoingMap.delete(responseId);
|
||||||
|
|
||||||
if (!request) {
|
if (!resolve) {
|
||||||
throw new Error(`Received response for unknown request ${responseId}`);
|
throw new Error(`Received response for unknown request ${responseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
request.response = dropNull(response);
|
resolve({
|
||||||
|
status: response.status ?? -1,
|
||||||
let callback = request.error;
|
message: response.message ?? '',
|
||||||
|
response: dropNull(response.body),
|
||||||
const status = response.status ?? -1;
|
headers: response.headers ?? [],
|
||||||
if (status >= 200 && status < 300) {
|
});
|
||||||
callback = request.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof callback === 'function') {
|
|
||||||
callback(response.message ?? '', status, request);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onClose(): void {
|
||||||
|
const outgoing = new Map(this.outgoingMap);
|
||||||
|
this.outgoingMap.clear();
|
||||||
|
|
||||||
|
for (const resolve of outgoing.values()) {
|
||||||
|
resolve({
|
||||||
|
status: 500,
|
||||||
|
message: 'Connection closed',
|
||||||
|
response: undefined,
|
||||||
|
headers: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private addActive(request: IncomingWebSocketRequest | number): void {
|
||||||
|
this.activeRequests.add(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeActive(request: IncomingWebSocketRequest | number): void {
|
||||||
|
if (!this.activeRequests.has(request)) {
|
||||||
|
window.log.warn('WebSocketResource: removing unknown request');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.activeRequests.delete(request);
|
||||||
|
if (this.activeRequests.size !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.shuttingDown) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.shutdownTimer) {
|
||||||
|
clearTimeout(this.shutdownTimer);
|
||||||
|
this.shutdownTimer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.log.info('WebSocketResource: shutdown complete');
|
||||||
|
this.close(3000, 'Shutdown');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -307,7 +407,7 @@ class KeepAlive {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
}
|
}
|
||||||
|
|
||||||
public send(): void {
|
public async send(): Promise<void> {
|
||||||
this.clearTimers();
|
this.clearTimers();
|
||||||
|
|
||||||
if (isOlderThan(this.lastAliveAt, MAX_KEEPALIVE_INTERVAL_MS)) {
|
if (isOlderThan(this.lastAliveAt, MAX_KEEPALIVE_INTERVAL_MS)) {
|
||||||
|
@ -332,11 +432,14 @@ class KeepAlive {
|
||||||
}
|
}
|
||||||
|
|
||||||
window.log.info('WebSocketResources: Sending a keepalive message');
|
window.log.info('WebSocketResources: Sending a keepalive message');
|
||||||
this.wsr.sendRequest({
|
const { status } = await this.wsr.sendRequest({
|
||||||
verb: 'GET',
|
verb: 'GET',
|
||||||
path: this.path,
|
path: this.path,
|
||||||
success: this.reset.bind(this),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (status >= 200 || status < 300) {
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public reset(): void {
|
public reset(): void {
|
||||||
|
|
61
ts/textsecure/downloadAttachment.ts
Normal file
61
ts/textsecure/downloadAttachment.ts
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
// Copyright 2020-2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { isNumber, omit } from 'lodash';
|
||||||
|
|
||||||
|
import { strictAssert } from '../util/assert';
|
||||||
|
import { dropNull } from '../util/dropNull';
|
||||||
|
import { DownloadedAttachmentType } from '../types/Attachment';
|
||||||
|
import * as MIME from '../types/MIME';
|
||||||
|
import * as Bytes from '../Bytes';
|
||||||
|
import { typedArrayToArrayBuffer } from '../Crypto';
|
||||||
|
|
||||||
|
import Crypto from './Crypto';
|
||||||
|
import { ProcessedAttachment } from './Types.d';
|
||||||
|
import type { WebAPIType } from './WebAPI';
|
||||||
|
|
||||||
|
export async function downloadAttachment(
|
||||||
|
server: WebAPIType,
|
||||||
|
attachment: ProcessedAttachment
|
||||||
|
): Promise<DownloadedAttachmentType> {
|
||||||
|
const cdnId = attachment.cdnId || attachment.cdnKey;
|
||||||
|
const { cdnNumber } = attachment;
|
||||||
|
|
||||||
|
if (!cdnId) {
|
||||||
|
throw new Error('downloadAttachment: Attachment was missing cdnId!');
|
||||||
|
}
|
||||||
|
|
||||||
|
strictAssert(cdnId, 'attachment without cdnId');
|
||||||
|
const encrypted = await server.getAttachment(cdnId, dropNull(cdnNumber));
|
||||||
|
const { key, digest, size, contentType } = attachment;
|
||||||
|
|
||||||
|
if (!digest) {
|
||||||
|
throw new Error('Failure: Ask sender to update Signal and resend.');
|
||||||
|
}
|
||||||
|
|
||||||
|
strictAssert(key, 'attachment has no key');
|
||||||
|
strictAssert(digest, 'attachment has no digest');
|
||||||
|
|
||||||
|
const paddedData = await Crypto.decryptAttachment(
|
||||||
|
encrypted,
|
||||||
|
typedArrayToArrayBuffer(Bytes.fromBase64(key)),
|
||||||
|
typedArrayToArrayBuffer(Bytes.fromBase64(digest))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isNumber(size)) {
|
||||||
|
throw new Error(
|
||||||
|
`downloadAttachment: Size was not provided, actual size was ${paddedData.byteLength}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = window.Signal.Crypto.getFirstBytes(paddedData, size);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...omit(attachment, 'digest', 'key'),
|
||||||
|
|
||||||
|
contentType: contentType
|
||||||
|
? MIME.fromString(contentType)
|
||||||
|
: MIME.APPLICATION_OCTET_STREAM,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
|
@ -7,7 +7,6 @@ import MessageReceiver from './MessageReceiver';
|
||||||
import utils from './Helpers';
|
import utils from './Helpers';
|
||||||
import Crypto from './Crypto';
|
import Crypto from './Crypto';
|
||||||
import { ContactBuffer, GroupBuffer } from './ContactsParser';
|
import { ContactBuffer, GroupBuffer } from './ContactsParser';
|
||||||
import createTaskWithTimeout from './TaskWithTimeout';
|
|
||||||
import SyncRequest from './SyncRequest';
|
import SyncRequest from './SyncRequest';
|
||||||
import MessageSender from './SendMessage';
|
import MessageSender from './SendMessage';
|
||||||
import StringView from './StringView';
|
import StringView from './StringView';
|
||||||
|
@ -16,7 +15,6 @@ import * as WebAPI from './WebAPI';
|
||||||
import WebSocketResource from './WebsocketResources';
|
import WebSocketResource from './WebsocketResources';
|
||||||
|
|
||||||
export const textsecure = {
|
export const textsecure = {
|
||||||
createTaskWithTimeout,
|
|
||||||
crypto: Crypto,
|
crypto: Crypto,
|
||||||
utils,
|
utils,
|
||||||
storage: new Storage(),
|
storage: new Storage(),
|
||||||
|
|
|
@ -81,12 +81,16 @@ export class User extends EventEmitter {
|
||||||
? this.storage.put('device_name', deviceName)
|
? this.storage.put('device_name', deviceName)
|
||||||
: Promise.resolve(),
|
: Promise.resolve(),
|
||||||
]);
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
window.log.info('storage.user: credentials changed');
|
public emitCredentialsChanged(reason: string): void {
|
||||||
|
window.log.info(`storage.user: credentials changed, ${reason}`);
|
||||||
this.emit('credentialsChange');
|
this.emit('credentialsChange');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async removeCredentials(): Promise<void> {
|
public async removeCredentials(): Promise<void> {
|
||||||
|
window.log.info('storage.user: removeCredentials');
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.storage.remove('number_id'),
|
this.storage.remove('number_id'),
|
||||||
this.storage.remove('uuid_id'),
|
this.storage.remove('uuid_id'),
|
||||||
|
|
38
ts/util/AbortableProcess.ts
Normal file
38
ts/util/AbortableProcess.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
/* eslint-disable no-restricted-syntax */
|
||||||
|
|
||||||
|
import { explodePromise } from './explodePromise';
|
||||||
|
|
||||||
|
export interface IController {
|
||||||
|
abort(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbortableProcess<Result> implements IController {
|
||||||
|
private abortReject: (error: Error) => void;
|
||||||
|
|
||||||
|
public readonly resultPromise: Promise<Result>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly name: string,
|
||||||
|
private readonly controller: IController,
|
||||||
|
resultPromise: Promise<Result>
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
promise: abortPromise,
|
||||||
|
reject: abortReject,
|
||||||
|
} = explodePromise<Result>();
|
||||||
|
|
||||||
|
this.abortReject = abortReject;
|
||||||
|
this.resultPromise = Promise.race([abortPromise, resultPromise]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public abort(): void {
|
||||||
|
this.controller.abort();
|
||||||
|
this.abortReject(new Error(`Process "${this.name}" was aborted`));
|
||||||
|
}
|
||||||
|
|
||||||
|
public getResult(): Promise<Result> {
|
||||||
|
return this.resultPromise;
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { AttachmentType, DownloadedAttachmentType } from '../types/Attachment';
|
import { AttachmentType, DownloadedAttachmentType } from '../types/Attachment';
|
||||||
|
import { downloadAttachment as doDownloadAttachment } from '../textsecure/downloadAttachment';
|
||||||
|
|
||||||
export async function downloadAttachment(
|
export async function downloadAttachment(
|
||||||
attachmentData: AttachmentType
|
attachmentData: AttachmentType
|
||||||
|
@ -20,7 +21,8 @@ export async function downloadAttachment(
|
||||||
|
|
||||||
let downloaded;
|
let downloaded;
|
||||||
try {
|
try {
|
||||||
downloaded = await window.textsecure.messageReceiver.downloadAttachment(
|
downloaded = await doDownloadAttachment(
|
||||||
|
window.textsecure.server,
|
||||||
migratedAttachment
|
migratedAttachment
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
25
ts/util/explodePromise.ts
Normal file
25
ts/util/explodePromise.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export function explodePromise<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
} {
|
||||||
|
let resolve: (value: T) => void;
|
||||||
|
let reject: (error: Error) => void;
|
||||||
|
|
||||||
|
const promise = new Promise<T>((innerResolve, innerReject) => {
|
||||||
|
resolve = innerResolve;
|
||||||
|
reject = innerReject;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
// Typescript thinks that resolve and reject can be undefined here.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
resolve: resolve!,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
reject: reject!,
|
||||||
|
};
|
||||||
|
}
|
|
@ -19,10 +19,12 @@ declare global {
|
||||||
window.waitBatchers = [];
|
window.waitBatchers = [];
|
||||||
|
|
||||||
window.flushAllWaitBatchers = async () => {
|
window.flushAllWaitBatchers = async () => {
|
||||||
|
window.log.info('waitBatcher#flushAllWaitBatchers');
|
||||||
await Promise.all(window.waitBatchers.map(item => item.flushAndWait()));
|
await Promise.all(window.waitBatchers.map(item => item.flushAndWait()));
|
||||||
};
|
};
|
||||||
|
|
||||||
window.waitForAllWaitBatchers = async () => {
|
window.waitForAllWaitBatchers = async () => {
|
||||||
|
window.log.info('waitBatcher#waitForAllWaitBatchers');
|
||||||
await Promise.all(window.waitBatchers.map(item => item.onIdle()));
|
await Promise.all(window.waitBatchers.map(item => item.onIdle()));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
2
ts/window.d.ts
vendored
2
ts/window.d.ts
vendored
|
@ -483,7 +483,7 @@ declare global {
|
||||||
getServerTrustRoot: () => WhatIsThis;
|
getServerTrustRoot: () => WhatIsThis;
|
||||||
readyForUpdates: () => void;
|
readyForUpdates: () => void;
|
||||||
logAppLoadedEvent: (options: { processedCount?: number }) => void;
|
logAppLoadedEvent: (options: { processedCount?: number }) => void;
|
||||||
logMessageReceiverConnect: () => void;
|
logAuthenticatedConnect: () => void;
|
||||||
|
|
||||||
// Runtime Flags
|
// Runtime Flags
|
||||||
isShowingModal?: boolean;
|
isShowingModal?: boolean;
|
||||||
|
|
Loading…
Reference in a new issue