diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b07b94851..fca857e8f 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1006,6 +1006,9 @@ "sendMessageLeftGroup": { "message": "You left this group" }, + "sendMessageBlockedUser": { + "message": "You have blocked the user" + }, "groupMembers": { "message": "Group members" }, diff --git a/background.html b/background.html index e97a9f00e..ad3063e11 100644 --- a/background.html +++ b/background.html @@ -463,7 +463,6 @@ - diff --git a/background_test.html b/background_test.html index de4dac8ae..cbb09488c 100644 --- a/background_test.html +++ b/background_test.html @@ -463,7 +463,6 @@ - diff --git a/js/background.js b/js/background.js index f342aeab9..cf695b3d3 100644 --- a/js/background.js +++ b/js/background.js @@ -430,8 +430,8 @@ await Promise.all([ ConversationController.load(), textsecure.storage.protocol.hydrateCaches(), + BlockedNumberController.load(), ]); - BlockedNumberController.refresh(); } catch (error) { window.log.error( 'background.js: ConversationController failed to load:', diff --git a/js/blocked_number_controller.js b/js/blocked_number_controller.js deleted file mode 100644 index 7e1a30ab6..000000000 --- a/js/blocked_number_controller.js +++ /dev/null @@ -1,68 +0,0 @@ -/* global , Whisper, storage */ -/* global textsecure: false */ - -/* eslint-disable more/no-then */ - -// eslint-disable-next-line func-names -(function() { - 'use strict'; - - window.Whisper = window.Whisper || {}; - - const blockedNumbers = new Whisper.BlockedNumberCollection(); - window.getBlockedNumbers = () => blockedNumbers; - - window.BlockedNumberController = { - reset() { - this.unblockAll(); - blockedNumbers.reset([]); - }, - refresh() { - window.log.info('BlockedNumberController: starting initial fetch'); - - if (!storage) { - throw new Error( - 'BlockedNumberController: Could not load blocked numbers' - ); - } - - // Add the numbers to the collection - const numbers = storage.getBlockedNumbers(); - blockedNumbers.reset(numbers.map(number => ({ number }))); - }, - block(number) { - const ourNumber = textsecure.storage.user.getNumber(); - - // Make sure we don't block ourselves - if (ourNumber === number) { - window.log.info('BlockedNumberController: Cannot block yourself!'); - return; - } - - storage.addBlockedNumber(number); - - // Make sure we don't add duplicates - if (blockedNumbers.getModel(number)) { - return; - } - - blockedNumbers.add({ number }); - }, - unblock(number) { - storage.removeBlockedNumber(number); - - // Remove the model from our collection - const model = blockedNumbers.getModel(number); - if (model) { - blockedNumbers.remove(model); - } - }, - unblockAll() { - const numbers = blockedNumbers.map(m => m.get('number')); - numbers.forEach(n => this.unblock(n)); - }, - isBlocked(number) { - return storage.isBlocked(number); - }, - }; -})(); diff --git a/js/conversation_controller.js b/js/conversation_controller.js index e4ad6d87c..86c228e57 100644 --- a/js/conversation_controller.js +++ b/js/conversation_controller.js @@ -196,7 +196,12 @@ }, getOrCreateAndWait(id, type) { return this._initialPromise.then(() => { - const pubkey = id.key ? id.key : id; + if (!id) { + return Promise.reject( + new Error('getOrCreateAndWait: invalid id passed.') + ); + } + const pubkey = id && id.key ? id.key : id; const conversation = this.getOrCreate(pubkey, type); if (conversation) { diff --git a/js/models/conversations.js b/js/models/conversations.js index 06f490fdb..6e7174669 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -223,20 +223,48 @@ return !!(this.id && this.id.match(/^rss:/)); }, isBlocked() { - return BlockedNumberController.isBlocked(this.id); + if (!this.id || this.isMe()) { + return false; + } + + if (this.isClosedGroup()) { + return BlockedNumberController.isGroupBlocked(this.id); + } + + if (this.isPrivate()) { + const primary = this.getPrimaryDevicePubKey(); + return BlockedNumberController.isBlocked(primary); + } + + return false; }, isMediumGroup() { return this.get('is_medium_group'); }, - block() { - BlockedNumberController.block(this.id); + async block() { + if (!this.id || this.isPublic() || this.isRss()) { + return; + } + + const promise = this.isPrivate() + ? BlockedNumberController.block(this.id) + : BlockedNumberController.blockGroup(this.id); + await promise; this.trigger('change'); this.messageCollection.forEach(m => m.trigger('change')); + this.updateTextInputState(); }, - unblock() { - BlockedNumberController.unblock(this.id); + async unblock() { + if (!this.id || this.isPublic() || this.isRss()) { + return; + } + const promise = this.isPrivate() + ? BlockedNumberController.unblock(this.id) + : BlockedNumberController.unblockGroup(this.id); + await promise; this.trigger('change'); this.messageCollection.forEach(m => m.trigger('change')); + this.updateTextInputState(); }, setMessageSelectionBackdrop() { const messageSelected = this.selectedMessages.size > 0; @@ -772,6 +800,11 @@ this.trigger('change:placeholder', 'left-group'); return; } + if (this.isBlocked()) { + this.trigger('disable:input', true); + this.trigger('change:placeholder', 'blocked-user'); + return; + } // otherwise, enable the input and set default placeholder this.trigger('disable:input', false); this.trigger('change:placeholder', 'chat'); diff --git a/js/signal_protocol_store.js b/js/signal_protocol_store.js index 3aa5a9aad..d5b77b9d9 100644 --- a/js/signal_protocol_store.js +++ b/js/signal_protocol_store.js @@ -898,7 +898,7 @@ ConversationController.reset(); BlockedNumberController.reset(); await ConversationController.load(); - BlockedNumberController.refresh(); + await BlockedNumberController.load(); }, async removeAllConfiguration() { await window.Signal.Data.removeAllConfiguration(); diff --git a/js/views/blocked_number_view.js b/js/views/blocked_number_view.js deleted file mode 100644 index cc14c1c48..000000000 --- a/js/views/blocked_number_view.js +++ /dev/null @@ -1,102 +0,0 @@ -/* global BlockedNumberController: false */ -/* global getBlockedNumbers: false */ -/* global Whisper: false */ -/* global storage: false */ -/* global i18n: false */ - -/* eslint-disable no-new */ - -// eslint-disable-next-line func-names -(function() { - 'use strict'; - - window.Whisper = window.Whisper || {}; - - Whisper.BlockedNumberView = Whisper.View.extend({ - templateName: 'blockedUserSettings', - className: 'blockedUserSettings', - events: { - 'click .unblock-button': 'onUnblock', - }, - initialize() { - storage.onready(() => { - BlockedNumberController.refresh(); - this.collection = getBlockedNumbers(); - this.listView = new Whisper.BlockedNumberListView({ - collection: this.collection, - }); - - this.listView.render(); - this.blockedUserSettings = this.$('.blocked-user-settings'); - this.blockedUserSettings.prepend(this.listView.el); - }); - }, - render_attributes() { - return { - blockedHeader: i18n('settingsUnblockHeader'), - unblockMessage: i18n('unblockUser'), - }; - }, - onUnblock() { - const number = this.$('select option:selected').val(); - if (!number) { - return; - } - - if (BlockedNumberController.isBlocked(number)) { - BlockedNumberController.unblock(number); - window.onUnblockNumber(number); - this.listView.collection.remove( - this.listView.collection.where({ number }) - ); - } - }, - }); - - Whisper.BlockedNumberListView = Whisper.View.extend({ - tagName: 'select', - initialize(options) { - this.options = options || {}; - this.listenTo(this.collection, 'add', this.addOne); - this.listenTo(this.collection, 'reset', this.addAll); - this.listenTo(this.collection, 'remove', this.addAll); - }, - addOne(model) { - const number = model.get('number'); - if (number) { - this.$el.append( - `` - ); - } - }, - addAll() { - this.$el.html(''); - this.collection.each(this.addOne, this); - }, - truncate(string, limit) { - // Make sure an element and number of items to truncate is provided - if (!string || !limit) { - return string; - } - - // Get the inner content of the element - let content = string.trim(); - - // Convert the content into an array of words - // Remove any words above the limit - content = content.slice(0, limit); - - // Convert the array of words back into a string - // If there's content to add after it, add it - if (string.length > limit) { - content = `${content}...`; - } - - return content; - }, - render() { - this.addAll(); - return this; - }, - }); -})(); diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index a7010412c..d139cbed6 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -10,6 +10,7 @@ textsecure, Whisper, ConversationController, + BlockedNumberController, */ // eslint-disable-next-line func-names @@ -535,6 +536,9 @@ case 'left-group': placeholder = i18n('sendMessageLeftGroup'); break; + case 'blocked-user': + placeholder = i18n('sendMessageBlockedUser'); + break; default: placeholder = i18n('sendMessage'); break; @@ -1907,11 +1911,17 @@ toastOptions.id = 'clockOutOfSync'; } } - if (this.model.isPrivate() && storage.isBlocked(this.model.id)) { + if ( + this.model.isPrivate() && + BlockedNumberController.isBlocked(this.model.id) + ) { toastOptions.title = i18n('unblockToSend'); toastOptions.id = 'unblockToSend'; } - if (!this.model.isPrivate() && storage.isGroupBlocked(this.model.id)) { + if ( + !this.model.isPrivate() && + BlockedNumberController.isGroupBlocked(this.model.id) + ) { toastOptions.title = i18n('unblockGroupToSend'); toastOptions.id = 'unblockGroupToSend'; } diff --git a/preload.js b/preload.js index c9f918a8e..2337f2171 100644 --- a/preload.js +++ b/preload.js @@ -195,7 +195,7 @@ window.resetDatabase = () => { ipc.send('resetDatabase'); }; -window.onUnblockNumber = number => { +window.onUnblockNumber = async number => { // Unblock the number if (window.BlockedNumberController) { window.BlockedNumberController.unblock(number); @@ -205,7 +205,7 @@ window.onUnblockNumber = number => { if (window.ConversationController) { try { const conversation = window.ConversationController.get(number); - conversation.unblock(); + await conversation.unblock(); } catch (e) { window.log.info( 'IPC on unblock: failed to fetch conversation for number: ', @@ -495,3 +495,11 @@ if (config.environment.includes('test-integration')) { enableSenderKeys: true, }; } + +// Blocking + +const { + BlockedNumberController, +} = require('./ts/util/blockedNumberController'); + +window.BlockedNumberController = BlockedNumberController; diff --git a/test/blocked_number_controller_test.js b/test/blocked_number_controller_test.js deleted file mode 100644 index 1c904065f..000000000 --- a/test/blocked_number_controller_test.js +++ /dev/null @@ -1,163 +0,0 @@ -/* global textsecure, BlockedNumberController, storage */ - -'use strict'; - -describe('Blocked Number Controller', () => { - beforeEach(async () => { - // Purge everything manually - const numbers = storage.getBlockedNumbers(); - numbers.forEach(storage.removeBlockedNumber); - window.getBlockedNumbers().reset([]); - }); - - describe('reset', () => { - it('clears blocked numbers', () => { - BlockedNumberController.block('1'); - assert.isNotEmpty(storage.getBlockedNumbers()); - assert.isNotEmpty(window.getBlockedNumbers().models); - - BlockedNumberController.reset(); - assert.isEmpty(storage.getBlockedNumbers()); - assert.isEmpty(window.getBlockedNumbers().models); - }); - }); - - describe('refresh', () => { - it('loads blocked numbers from storage', () => { - BlockedNumberController.refresh(); - assert.isEmpty(window.getBlockedNumbers().models); - - storage.addBlockedNumber('1'); - storage.addBlockedNumber('2'); - BlockedNumberController.refresh(); - - const blocked = window.getBlockedNumbers().map(m => m.get('number')); - assert.lengthOf(blocked, 2); - assert.deepEqual(['1', '2'], blocked.sort()); - }); - - it('overrides old numbers if we refresh again', () => { - BlockedNumberController.refresh(); - assert.isEmpty(window.getBlockedNumbers().models); - - storage.addBlockedNumber('1'); - BlockedNumberController.refresh(); - assert.isNotEmpty( - window.getBlockedNumbers().find(m => m.get('number') === '1') - ); - - storage.removeBlockedNumber('1'); - storage.addBlockedNumber('2'); - BlockedNumberController.refresh(); - assert.isNotEmpty( - window.getBlockedNumbers().find(m => m.get('number') === '2') - ); - }); - - it('throws if storage is invalid', () => { - const _storage = window.storage; - window.storage = null; - assert.throws( - () => BlockedNumberController.refresh(), - 'BlockedNumberController: Could not load blocked numbers' - ); - window.storage = _storage; - }); - }); - - describe('block', () => { - beforeEach(() => { - BlockedNumberController.refresh(); - assert.isEmpty(storage.getBlockedNumbers()); - assert.isEmpty(window.getBlockedNumbers().models); - }); - - it('adds number to the blocked list', () => { - BlockedNumberController.block('1'); - - const numbers = window.getBlockedNumbers().models; - assert.lengthOf(numbers, 1); - assert.strictEqual('1', numbers[0].get('number')); - assert.deepEqual(['1'], storage.getBlockedNumbers()); - }); - - it('only blocks the same number once', () => { - BlockedNumberController.block('2'); - BlockedNumberController.block('2'); - assert.lengthOf(window.getBlockedNumbers().models, 1); - assert.deepEqual(['2'], storage.getBlockedNumbers()); - }); - - it('does not block our own number', () => { - BlockedNumberController.block(textsecure.storage.user.getNumber()); - assert.isEmpty(window.getBlockedNumbers().models); - assert.isEmpty(storage.getBlockedNumbers()); - }); - }); - - describe('unblock', () => { - beforeEach(() => { - BlockedNumberController.refresh(); - assert.isEmpty(storage.getBlockedNumbers()); - assert.isEmpty(window.getBlockedNumbers().models); - }); - - it('removes number from the blocked list', () => { - BlockedNumberController.block('1'); - BlockedNumberController.block('2'); - - assert.lengthOf(window.getBlockedNumbers().models, 2); - assert.lengthOf(storage.getBlockedNumbers(), 2); - - BlockedNumberController.unblock('1'); - - const numbers = window.getBlockedNumbers().models; - assert.lengthOf(numbers, 1); - assert.isEmpty(numbers.filter(n => n.get('number') === '1')); - assert.deepEqual(['2'], storage.getBlockedNumbers()); - }); - - it('removes number from the blocked list even if it is not present in the collection', () => { - BlockedNumberController.block('1'); - BlockedNumberController.block('2'); - window.getBlockedNumbers().reset([]); - - assert.isEmpty(window.getBlockedNumbers().models); - assert.lengthOf(storage.getBlockedNumbers(), 2); - - BlockedNumberController.unblock('1'); - assert.deepEqual(['2'], storage.getBlockedNumbers()); - }); - }); - - describe('unblockAll', () => { - it('removes all our blocked numbers', () => { - BlockedNumberController.refresh(); - - BlockedNumberController.block('1'); - BlockedNumberController.block('2'); - BlockedNumberController.block('3'); - - assert.lengthOf(window.getBlockedNumbers().models, 3); - assert.lengthOf(storage.getBlockedNumbers(), 3); - - BlockedNumberController.unblockAll(); - - assert.lengthOf(window.getBlockedNumbers().models, 0); - assert.lengthOf(storage.getBlockedNumbers(), 0); - }); - }); - - describe('isBlocked', () => { - it('returns whether a number is blocked', () => { - BlockedNumberController.refresh(); - - BlockedNumberController.block('1'); - assert.isOk(BlockedNumberController.isBlocked('1')); - assert.isNotOk(BlockedNumberController.isBlocked('2')); - - BlockedNumberController.unblock('1'); - assert.isNotOk(BlockedNumberController.isBlocked('1')); - }); - }); -}); diff --git a/test/index.html b/test/index.html index eb6a097c9..c98043693 100644 --- a/test/index.html +++ b/test/index.html @@ -505,7 +505,6 @@ - @@ -574,7 +573,6 @@ - diff --git a/ts/receiver/contentMessage.ts b/ts/receiver/contentMessage.ts index b8b6e310a..3e8d105b9 100644 --- a/ts/receiver/contentMessage.ts +++ b/ts/receiver/contentMessage.ts @@ -18,6 +18,7 @@ import { PubKey } from '../session/types'; import { handleSyncMessage } from './syncMessages'; import { onError } from './errors'; import ByteBuffer from 'bytebuffer'; +import { BlockedNumberController } from '../util/blockedNumberController'; export async function handleContentMessage(envelope: EnvelopePlus) { const plaintext = await decrypt(envelope, envelope.content); @@ -95,10 +96,8 @@ function unpad(paddedData: ArrayBuffer): ArrayBuffer { throw new Error('Invalid padding'); } -export function isBlocked(number: string) { - // TODO: should probably use primary pubkeys here! - const blockedNumbers = window.textsecure.storage.get('blocked', []); - return blockedNumbers.indexOf(number) >= 0; +export async function isBlocked(number: string) { + return BlockedNumberController.isBlockedAsync(number); } async function decryptPreKeyWhisperMessage( @@ -152,7 +151,9 @@ async function decryptUnidentifiedSender( const { sender: source } = error || {}; if (source) { - if (isBlocked(source.getName())) { + // tslint:disable-next-line: no-shadowed-variable + const blocked = await isBlocked(source.getName()); + if (blocked) { window.log.info( 'Dropping blocked message with error after sealed sender decryption' ); @@ -187,7 +188,8 @@ async function decryptUnidentifiedSender( envelope.type = SignalService.Envelope.Type.SESSION_REQUEST; } - if (isBlocked(sender.getName())) { + const blocked = await isBlocked(sender.getName()); + if (blocked) { window.log.info('Dropping blocked message after sealed sender decryption'); return null; } diff --git a/ts/receiver/groups.ts b/ts/receiver/groups.ts index bac23f4f0..dedfd0ddc 100644 --- a/ts/receiver/groups.ts +++ b/ts/receiver/groups.ts @@ -3,11 +3,10 @@ import { ClosedGroupRequestInfoMessage } from '../session/messages/outgoing/cont import { getMessageQueue } from '../session'; import { PubKey } from '../session/types'; import _ from 'lodash'; +import { BlockedNumberController } from '../util/blockedNumberController'; function isGroupBlocked(groupId: string) { - return ( - window.textsecure.storage.get('blocked-groups', []).indexOf(groupId) >= 0 - ); + return BlockedNumberController.isGroupBlocked(groupId); } function shouldIgnoreBlockedGroup(group: any, senderPubKey: string) { diff --git a/ts/receiver/receiver.ts b/ts/receiver/receiver.ts index 3b50cdb93..a4f31f11e 100644 --- a/ts/receiver/receiver.ts +++ b/ts/receiver/receiver.ts @@ -30,6 +30,7 @@ import { import { getEnvelopeId } from './common'; import { StringUtils } from '../session/utils'; import { SignalService } from '../protobuf'; +import { BlockedNumberController } from '../util/blockedNumberController'; // TODO: check if some of these exports no longer needed export { @@ -152,7 +153,8 @@ async function handleRequestDetail( // TODO: 'source' is almost certainly undefined here (sealed sender), // so this check is not appropriate here - if (isBlocked(envelope.source)) { + const blocked = await isBlocked(envelope.source); + if (blocked) { return; } diff --git a/ts/test/util/blockedNumberController_test.ts b/ts/test/util/blockedNumberController_test.ts new file mode 100644 index 000000000..02c1c7ce9 --- /dev/null +++ b/ts/test/util/blockedNumberController_test.ts @@ -0,0 +1,240 @@ +import { expect } from 'chai'; +import * as crypto from 'crypto'; +import Sinon, * as sinon from 'sinon'; +import { BlockedNumberController } from '../../util/blockedNumberController'; +import { TestUtils } from '../test-utils'; +import { PubKey } from '../../session/types'; +import { MultiDeviceProtocol } from '../../session/protocols'; +import { UserUtil } from '../../util'; + +describe('BlockedNumberController', () => { + const sandbox = sinon.createSandbox(); + let memoryDB: { [key: string]: any }; + beforeEach(() => { + memoryDB = {}; + + TestUtils.stubData('createOrUpdateItem').callsFake(data => { + memoryDB[data.id] = data.value; + }); + + TestUtils.stubData('getItemById').callsFake(id => { + if (!memoryDB[id]) { + return undefined; + } + const value = memoryDB[id]; + return { + id, + value, + }; + }); + + BlockedNumberController.reset(); + }); + + afterEach(() => { + sandbox.restore(); + TestUtils.restoreStubs(); + }); + + describe('load', async () => { + it('should load data from the database', async () => { + const normal = TestUtils.generateFakePubKey(); + const group = TestUtils.generateFakePubKey(); + memoryDB.blocked = [normal.key]; + memoryDB['blocked-groups'] = [group.key]; + await BlockedNumberController.load(); + + const blockedNumbers = BlockedNumberController.getBlockedNumbers(); + const blockedGroups = BlockedNumberController.getBlockedGroups(); + + expect(blockedNumbers).to.have.lengthOf(1); + expect(blockedNumbers).to.include(normal.key); + expect(blockedGroups).to.have.lengthOf(1); + expect(blockedGroups).to.include(group.key); + }); + + it('should return empty if nothing in the db exists', async () => { + await BlockedNumberController.load(); + const blockedNumbers = BlockedNumberController.getBlockedNumbers(); + const blockedGroups = BlockedNumberController.getBlockedGroups(); + + expect(blockedNumbers).to.be.empty; + expect(blockedGroups).to.be.empty; + }); + }); + + describe('block', async () => { + it('should block the primary device of the user', async () => { + const primary = TestUtils.generateFakePubKey(); + const secondary = TestUtils.generateFakePubKey(); + sandbox.stub(MultiDeviceProtocol, 'getPrimaryDevice').resolves(primary); + + await BlockedNumberController.block(secondary); + + const blockedNumbers = BlockedNumberController.getBlockedNumbers(); + expect(blockedNumbers).to.have.lengthOf(1); + expect(blockedNumbers).to.include(primary.key); + expect(memoryDB.blocked).to.include(primary.key); + expect(BlockedNumberController.getBlockedGroups()).to.be.empty; + }); + }); + + describe('unblock', async () => { + it('should unblock the primary device', async () => { + const primary = TestUtils.generateFakePubKey(); + const secondary = TestUtils.generateFakePubKey(); + memoryDB.blocked = [primary.key]; + sandbox.stub(MultiDeviceProtocol, 'getPrimaryDevice').resolves(primary); + + await BlockedNumberController.unblock(secondary); + + const blockedNumbers = BlockedNumberController.getBlockedNumbers(); + expect(blockedNumbers).to.be.empty; + expect(memoryDB.blocked).to.be.empty; + }); + + it('should only unblock if a device was blocked', async () => { + const pubKey = TestUtils.generateFakePubKey(); + const another = TestUtils.generateFakePubKey(); + memoryDB.blocked = [pubKey.key, another.key]; + sandbox.stub(MultiDeviceProtocol, 'getPrimaryDevice').resolves(pubKey); + + await BlockedNumberController.unblock(pubKey); + + const blockedNumbers = BlockedNumberController.getBlockedNumbers(); + expect(blockedNumbers).to.have.lengthOf(1); + expect(blockedNumbers).to.include(another.key); + expect(memoryDB.blocked).to.have.lengthOf(1); + expect(memoryDB.blocked).to.include(another.key); + }); + }); + + describe('blockGroup', async () => { + it('should block a group', async () => { + const group = TestUtils.generateFakePubKey(); + + await BlockedNumberController.blockGroup(group); + + const blockedGroups = BlockedNumberController.getBlockedGroups(); + expect(blockedGroups).to.have.lengthOf(1); + expect(blockedGroups).to.include(group.key); + expect(memoryDB['blocked-groups']).to.have.lengthOf(1); + expect(memoryDB['blocked-groups']).to.include(group.key); + expect(BlockedNumberController.getBlockedNumbers()).to.be.empty; + }); + }); + + describe('unblockGroup', async () => { + it('should unblock a group', async () => { + const group = TestUtils.generateFakePubKey(); + const another = TestUtils.generateFakePubKey(); + memoryDB['blocked-groups'] = [group.key, another.key]; + + await BlockedNumberController.unblockGroup(group); + + const blockedGroups = BlockedNumberController.getBlockedGroups(); + expect(blockedGroups).to.have.lengthOf(1); + expect(blockedGroups).to.include(another.key); + expect(memoryDB['blocked-groups']).to.have.lengthOf(1); + expect(memoryDB['blocked-groups']).to.include(another.key); + }); + }); + + describe('isBlocked', async () => { + it('should return true if number is blocked', async () => { + const pubKey = TestUtils.generateFakePubKey(); + const groupPubKey = TestUtils.generateFakePubKey(); + memoryDB.blocked = [pubKey.key]; + memoryDB['blocked-groups'] = [groupPubKey.key]; + await BlockedNumberController.load(); + expect(BlockedNumberController.isBlocked(pubKey.key)).to.equal( + true, + 'Expected isBlocked to return true for user pubkey' + ); + expect(BlockedNumberController.isBlocked(groupPubKey.key)).to.equal( + false, + 'Expected isBlocked to return false for a group pubkey' + ); + }); + + it('should return false if number is not blocked', async () => { + const pubKey = TestUtils.generateFakePubKey(); + memoryDB.blocked = []; + await BlockedNumberController.load(); + expect(BlockedNumberController.isBlocked(pubKey.key)).to.equal( + false, + 'Expected isBlocked to return false' + ); + }); + }); + + describe('isBlockedAsync', () => { + let ourDevices: Array; + beforeEach(() => { + ourDevices = TestUtils.generateFakePubKeys(2); + sandbox.stub(MultiDeviceProtocol, 'getOurDevices').resolves(ourDevices); + }); + it('should return false for our device', async () => { + for (const device of ourDevices) { + const isBlocked = await BlockedNumberController.isBlockedAsync(device); + expect(isBlocked).to.equal( + false, + 'Expected our devices to return false' + ); + } + }); + + it('should return true if the primary device is blocked', async () => { + const primary = TestUtils.generateFakePubKey(); + const secondary = TestUtils.generateFakePubKey(); + sandbox.stub(MultiDeviceProtocol, 'getPrimaryDevice').resolves(primary); + memoryDB.blocked = [primary.key]; + + const isBlocked = await BlockedNumberController.isBlockedAsync(secondary); + expect(isBlocked).to.equal( + true, + 'Expected isBlockedAsync to return true.' + ); + }); + + it('should return false if device is not blocked', async () => { + const primary = TestUtils.generateFakePubKey(); + sandbox.stub(MultiDeviceProtocol, 'getPrimaryDevice').resolves(primary); + memoryDB.blocked = []; + + const isBlocked = await BlockedNumberController.isBlockedAsync(primary); + expect(isBlocked).to.equal( + false, + 'Expected isBlockedAsync to return false.' + ); + }); + }); + + describe('isGroupBlocked', async () => { + it('should return true if group is blocked', async () => { + const pubKey = TestUtils.generateFakePubKey(); + const groupPubKey = TestUtils.generateFakePubKey(); + memoryDB.blocked = [pubKey.key]; + memoryDB['blocked-groups'] = [groupPubKey.key]; + await BlockedNumberController.load(); + expect(BlockedNumberController.isGroupBlocked(pubKey.key)).to.equal( + false, + 'Expected isGroupBlocked to return false for user pubkey' + ); + expect(BlockedNumberController.isGroupBlocked(groupPubKey.key)).to.equal( + true, + 'Expected isGroupBlocked to return true for a group pubkey' + ); + }); + + it('should return false if group is not blocked', async () => { + const groupPubKey = TestUtils.generateFakePubKey(); + memoryDB['blocked-groups'] = []; + await BlockedNumberController.load(); + expect(BlockedNumberController.isGroupBlocked(groupPubKey.key)).to.equal( + false, + 'Expected isGroupBlocked to return false' + ); + }); + }); +}); diff --git a/ts/util/blockedNumberController.ts b/ts/util/blockedNumberController.ts new file mode 100644 index 000000000..baaabe7d4 --- /dev/null +++ b/ts/util/blockedNumberController.ts @@ -0,0 +1,148 @@ +import { createOrUpdateItem, getItemById } from '../../js/modules/data'; +import { PubKey } from '../session/types'; +import { MultiDeviceProtocol } from '../session/protocols'; + +const BLOCKED_NUMBERS_ID = 'blocked'; +const BLOCKED_GROUPS_ID = 'blocked-groups'; + +// tslint:disable-next-line: no-unnecessary-class +export class BlockedNumberController { + private static loaded: boolean = false; + private static blockedNumbers: Set = new Set(); + private static blockedGroups: Set = new Set(); + + /** + * Check if a device is blocked. + * This will use `MultiDeviceProtocol` to determine wether a user is blocked or not. + * + * @param user The user. + */ + public static async isBlockedAsync(user: string | PubKey): Promise { + await this.load(); + const isOurDevice = await MultiDeviceProtocol.isOurDevice(user); + if (isOurDevice) { + return false; + } + + const primary = await MultiDeviceProtocol.getPrimaryDevice(user); + return this.blockedNumbers.has(primary.key); + } + + /** + * Check if a device is blocked synchronously. + * This will only check against the memory cache on if a device is blocked, it is reccomended to pass in the primary device pub key. + * + * Make sure `load()` has been called before this function so that the correct blocked state is returned. + * + * @param number The device. + */ + public static isBlocked(device: string | PubKey): boolean { + // This function is not `async` because the old `isBlocked` function in js was also not async. + // To convert it means we'll have to re-wire all our UI components to work with async. + const stringValue = + device instanceof PubKey ? device.key : device.toLowerCase(); + return this.blockedNumbers.has(stringValue); + } + + /** + * Check if a group id is blocked. + * Make sure `load()` has been called before this function so that the correct blocked state is returned. + * + * @param groupId The group id. + */ + public static isGroupBlocked(groupId: string | PubKey): boolean { + const stringValue = + groupId instanceof PubKey ? groupId.key : groupId.toLowerCase(); + return this.blockedGroups.has(stringValue); + } + + /** + * Block a user. + * This will only block the primary device of the user. + * + * @param user The user to block. + */ + public static async block(user: string | PubKey): Promise { + // The reason we add all linked device to block number set instead of checking if any device of a user is in the `isBlocked` function because + // `isBlocked` is used synchronously in the code. To check if any device is blocked needs it to be async, which would mean all calls to `isBlocked` will also need to be async and so on + // This is too much of a hassle at the moment as some UI code will have to be migrated to work with this async call. + await this.load(); + const primary = await MultiDeviceProtocol.getPrimaryDevice(user); + if (!this.blockedNumbers.has(primary.key)) { + this.blockedNumbers.add(primary.key); + await this.saveToDB(BLOCKED_NUMBERS_ID, this.blockedNumbers); + } + } + + /** + * Unblock a user. + * This will only unblock the primary device of the user. + * + * @param user The user to unblock. + */ + public static async unblock(user: string | PubKey): Promise { + await this.load(); + const primary = await MultiDeviceProtocol.getPrimaryDevice(user); + if (this.blockedNumbers.has(primary.key)) { + this.blockedNumbers.delete(primary.key); + await this.saveToDB(BLOCKED_NUMBERS_ID, this.blockedNumbers); + } + } + + public static async blockGroup(groupId: string | PubKey): Promise { + await this.load(); + const id = PubKey.cast(groupId); + this.blockedGroups.add(id.key); + await this.saveToDB(BLOCKED_GROUPS_ID, this.blockedGroups); + } + + public static async unblockGroup(groupId: string | PubKey): Promise { + await this.load(); + const id = PubKey.cast(groupId); + this.blockedGroups.delete(id.key); + await this.saveToDB(BLOCKED_GROUPS_ID, this.blockedGroups); + } + + public static getBlockedNumbers(): Array { + return [...this.blockedNumbers]; + } + + public static getBlockedGroups(): Array { + return [...this.blockedGroups]; + } + + // ---- DB + + public static async load() { + if (!this.loaded) { + this.blockedNumbers = await this.getNumbersFromDB(BLOCKED_NUMBERS_ID); + this.blockedGroups = await this.getNumbersFromDB(BLOCKED_GROUPS_ID); + this.loaded = true; + } + } + + public static reset() { + this.loaded = false; + this.blockedNumbers = new Set(); + this.blockedGroups = new Set(); + } + + private static async getNumbersFromDB(id: string): Promise> { + const data = await getItemById(id); + if (!data || !data.value) { + return new Set(); + } + + return new Set(data.value); + } + + private static async saveToDB( + id: string, + numbers: Set + ): Promise { + await createOrUpdateItem({ + id, + value: [...numbers], + }); + } +} diff --git a/ts/util/index.ts b/ts/util/index.ts index 8416f8630..c5428412b 100644 --- a/ts/util/index.ts +++ b/ts/util/index.ts @@ -6,6 +6,8 @@ import { migrateColor } from './migrateColor'; import { makeLookup } from './makeLookup'; import * as UserUtil from './user'; +export * from './blockedNumberController'; + export { arrayBufferToObjectURL, GoogleChrome,