UNPKG

@signalapp/mock-server

Version:
562 lines (561 loc) 19.1 kB
"use strict"; // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.StorageState = void 0; const assert_1 = __importDefault(require("assert")); const crypto_1 = __importDefault(require("crypto")); const node_buffer_1 = require("node:buffer"); const compiled_1 = require("../../protos/compiled"); const crypto_2 = require("../crypto"); const types_1 = require("../types"); const KEY_SIZE = 16; const IdentifierType = compiled_1.signalservice.ManifestRecord.Identifier.Type; class StorageStateItem { type; key; record; constructor({ type, key, record }) { this.type = type; this.key = key; this.record = record; } getKeyString() { return this.key.toString('base64'); } toStorageItem({ storageKey, recordIkm, }) { return (0, crypto_2.encryptStorageItem)({ storageKey, recordIkm, key: this.key, record: { record: this.record, }, }); } toIdentifier() { return { type: this.type, raw: this.key, }; } isAccount() { return this.type === IdentifierType.ACCOUNT && this.record.account != null; } isGroup(group) { if (this.type !== IdentifierType.GROUPV2) { return false; } (0, assert_1.default)(this.record.groupV2 != null, 'consistency check'); const masterKey = this.record.groupV2.masterKey; if (!masterKey) { return false; } return group.masterKey.equals(masterKey); } isContact(device, serviceIdKind) { if (this.type !== IdentifierType.CONTACT) { return false; } (0, assert_1.default)(this.record.contact != null, 'consistency check'); if (serviceIdKind === types_1.ServiceIdKind.ACI) { const existingAci = this.record.contact.aciBinary; if (!existingAci?.length) { return false; } return node_buffer_1.Buffer.compare(existingAci, device.aciRawUuid) === 0; } const existingPni = this.record.contact.pniBinary; if (!existingPni?.length) { return false; } return node_buffer_1.Buffer.compare(existingPni, device.pniRawUuid) === 0; } inspect() { return [ `type: ${this.type}`, `key: ${this.key.toString('base64')}`, ...JSON.stringify(this.record, null, 2).split(/\n/g), ] .map((line) => ` ${line}`) .join('\n'); } toRecord() { return { type: this.type, key: this.key, record: this.record, }; } } const EMPTY_CONTACT = { e164: null, profileKey: null, identityKey: null, identityState: null, givenName: null, familyName: null, username: null, blocked: null, whitelisted: null, archived: null, markedUnread: null, mutedUntilTimestamp: null, hideStory: null, unregisteredAtTimestamp: null, systemGivenName: null, systemFamilyName: null, systemNickname: null, hidden: null, pniSignatureVerified: null, nickname: null, note: null, avatarColor: null, aciBinary: null, pniBinary: null, }; const EMPTY_GROUP = { masterKey: null, blocked: null, whitelisted: null, archived: null, markedUnread: null, mutedUntilTimestamp: null, dontNotifyForMentionsIfMuted: null, hideStory: null, storySendMode: null, avatarColor: null, }; class StorageState { version; items; constructor(version, items) { this.version = version; this.items = items.map((options) => new StorageStateItem(options)); } static getEmpty() { return new StorageState(0n, [ new StorageStateItem({ key: StorageState.createStorageID(), type: IdentifierType.ACCOUNT, record: { record: 'account', account: { profileKey: null, givenName: null, familyName: null, avatarUrlPath: null, noteToSelfArchived: null, readReceipts: null, sealedSenderIndicators: null, typingIndicators: null, noteToSelfMarkedUnread: null, linkPreviews: null, phoneNumberSharingMode: null, unlistedPhoneNumber: null, pinnedConversations: null, preferContactAvatars: null, payments: null, universalExpireTimer: null, preferredReactionEmoji: null, donorSubscriberId: null, donorSubscriberCurrencyCode: null, displayBadgesOnProfile: null, donorSubscriptionManuallyCancelled: null, keepMutedChatsArchived: null, hasSetMyStoriesPrivacy: null, hasViewedOnboardingStory: null, storiesDisabled: null, storyViewReceiptsEnabled: null, hasSeenGroupStoryEducationSheet: null, username: null, hasCompletedUsernameOnboarding: null, usernameLink: null, hasBackup: null, backupTier: null, backupSubscriberData: null, avatarColor: null, notificationProfileManualOverride: null, notificationProfileSyncDisabled: null, }, }, }), ]); } // // Account // getAccountRecord() { const item = this.items.find((item) => item.isAccount()); if (!item) { return undefined; } return item.record.account; } updateAccount(diff) { return this.updateItem((item) => item.isAccount(), (record) => { return { record: 'account', account: { ...record.account, ...diff, }, }; }); } updateManyAccounts(diff) { return this.updateManyItems((item) => item.isAccount(), (record) => { return { record: 'account', account: { ...record.account, ...diff, }, }; }); } // // Group // getGroup(group) { const item = this.items.find((item) => item.isGroup(group)); if (!item) { return undefined; } return item.record.groupV2; } addGroup(group, diff = {}) { return this.addItem({ type: IdentifierType.GROUPV2, record: { groupV2: { ...EMPTY_GROUP, ...diff, masterKey: group.masterKey, }, }, }); } updateGroup(group, diff) { return this.updateItem((item) => item.isGroup(group), (record) => { return { groupV2: { ...record.groupV2, ...diff, }, }; }); } pinGroup(group) { return this.changeGroupPin(group, true); } unpinGroup(group) { return this.changeGroupPin(group, false); } isGroupPinned(group) { const account = this.getAccountRecord(); (0, assert_1.default)(account, 'No account record found'); return (account.pinnedConversations ?? []).some((convo) => { if (convo.identifier?.groupMasterKey == null) { return false; } return group.masterKey.equals(convo.identifier.groupMasterKey); }); } // // Contacts // addContact({ device }, diff = {}, serviceIdKind = types_1.ServiceIdKind.ACI) { return this.addItem({ type: IdentifierType.CONTACT, record: { contact: { ...EMPTY_CONTACT, aciBinary: serviceIdKind === types_1.ServiceIdKind.ACI ? device.aciRawUuid : null, pniBinary: serviceIdKind === types_1.ServiceIdKind.PNI ? device.pniRawUuid : null, e164: device.number, ...diff, }, }, }); } updateContact({ device }, diff, serviceIdKind = types_1.ServiceIdKind.ACI) { return this.updateItem((item) => item.isContact(device, serviceIdKind), (record) => { return { record: 'contact', contact: { ...record.contact, ...diff, }, }; }); } getContact({ device }, serviceIdKind = types_1.ServiceIdKind.ACI) { const item = this.items.find((item) => item.isContact(device, serviceIdKind)); if (!item) { return undefined; } return item.record.contact; } removeContact({ device }, serviceIdKind = types_1.ServiceIdKind.ACI) { return this.removeItem((item) => item.isContact(device, serviceIdKind)); } mergeContact(primary, diff) { const { device } = primary; return this.removeItem((item) => item.isContact(device, types_1.ServiceIdKind.ACI)) .removeItem((item) => item.isContact(device, types_1.ServiceIdKind.PNI)) .addContact(primary, { pniBinary: device.pniRawUuid, ...diff, }) .unpin(primary, types_1.ServiceIdKind.PNI); } pin(primary, serviceIdKind = types_1.ServiceIdKind.ACI) { return this.changePin(primary, serviceIdKind, true); } unpin(primary, serviceIdKind = types_1.ServiceIdKind.ACI) { return this.changePin(primary, serviceIdKind, false); } isPinned({ device }) { const account = this.getAccountRecord(); (0, assert_1.default)(account, 'No account record found'); return (account.pinnedConversations ?? []).some((convo) => { if (convo.identifier?.contact == null) { return false; } const existing = convo.identifier.contact.serviceIdBinary; return existing && node_buffer_1.Buffer.compare(existing, device.aciRawUuid) === 0; }); } // // Raw record access // addRecord(newRecord) { return this.addItem(newRecord); } findRecord(find) { const item = this.items.find((item) => { return find(item.toRecord()); }); return item?.toRecord(); } filterRecords(filter) { return this.items.filter((item) => filter(item.toRecord())); } hasRecord(find) { return (this.findRecord(find) !== undefined); } updateRecord(find, map) { return this.updateItem((item) => find(item.toRecord()), map); } updateManyRecords(filter, map) { return this.updateManyItems((item) => filter(item.toRecord()), map); } removeRecord(find) { return this.removeItem((item) => find(item.toRecord())); } removeManyRecords(filter) { return this.removeManyItems((item) => filter(item.toRecord())); } getAllGroupRecords() { return this.items .filter((item) => item.type === IdentifierType.GROUPV2) .map((item) => item.toRecord()); } hasKey(storageKey) { return this.hasRecord((item) => item.key.equals(storageKey)); } // // General // createWriteOperation({ storageKey, recordIkm, previous, }) { const newVersion = previous ? previous.version + 1n : this.version + 1n; const keysToDelete = new Set((previous?.items ?? []).map((item) => { return item.getKeyString(); })); const insertItem = new Array(); for (const item of this.items) { if (!keysToDelete.delete(item.getKeyString())) { insertItem.push(item.toStorageItem({ storageKey, recordIkm })); } } const manifest = (0, crypto_2.encryptStorageManifest)(storageKey, { version: newVersion, identifiers: this.items.map((item) => item.toIdentifier()), recordIkm: recordIkm ?? null, sourceDevice: null, }); return { manifest, insertItem, deleteKey: Array.from(keysToDelete).map((key) => { return node_buffer_1.Buffer.from(key, 'base64'); }), clearAll: null, }; } inspect() { return [ `version: ${this.version}`, ...this.items.map((item) => item.inspect()), ].join('\n'); } diff(oldState) { const addedIds = new Map(); const removedIds = new Map(); for (const item of this.items) { addedIds.set(item.key.toString('base64'), item.record); } for (const item of oldState.items) { const keyString = item.key.toString('base64'); if (!addedIds.delete(keyString)) { removedIds.set(keyString, item.record); } } return { added: Array.from(addedIds.values()), removed: Array.from(removedIds.values()), }; } // // Private // addItem(newRecord) { return this.replaceItem(this.items.length, newRecord); } findItemIndex(find) { const itemIndex = this.items.findIndex(find); if (itemIndex === -1) { throw new Error('Item not found'); } const otherIndex = this.items.findLastIndex(find); if (otherIndex !== itemIndex) { throw new Error('Found multiple items'); } return itemIndex; } updateItem(find, map) { const itemIndex = this.findItemIndex(find); const item = this.items[itemIndex]; (0, assert_1.default)(item, 'consistency check'); return this.replaceItem(itemIndex, { type: item.type, record: map(item.record), }); } updateManyItems(filter, map) { let updated = 0; const newItems = this.items.map((item, index) => { if (filter(item, index)) { updated += 1; return new StorageStateItem({ type: item.type, key: StorageState.createStorageID(), record: map(item.record), }); } else { return item; } }); if (updated === 0) { throw new Error('No items updated'); } return new StorageState(this.version, newItems); } replaceItem(index, { type, record, key = StorageState.createStorageID(), }) { const newItems = [ ...this.items.slice(0, index), new StorageStateItem({ type, key, record }), ...this.items.slice(index + 1), ]; return new StorageState(this.version, newItems); } removeItem(find) { const itemIndex = this.findItemIndex(find); const newItems = [ ...this.items.slice(0, itemIndex), ...this.items.slice(itemIndex + 1), ]; return new StorageState(this.version, newItems); } removeManyItems(filter) { const newItems = this.items.filter((item, index) => { return !filter(item, index); }); if (newItems.length === this.items.length) { throw new Error('No items removed'); } return new StorageState(this.version, newItems); } changePin({ device }, serviceIdKind, isPinned) { const deviceServiceIdBinary = device.getServiceIdBinaryByKind(serviceIdKind); return this.updateItem((item) => item.isAccount(), (record) => { const { account } = record; const { pinnedConversations } = account; const newPinnedConversations = pinnedConversations?.slice() ?? []; const existingIndex = newPinnedConversations.findIndex((convo) => { if (convo.identifier?.contact == null) { return false; } const existing = convo.identifier.contact.serviceIdBinary; return (existing && node_buffer_1.Buffer.compare(existing, deviceServiceIdBinary) === 0); }); if (isPinned && existingIndex === -1) { newPinnedConversations.push({ identifier: { contact: { e164: null, serviceIdBinary: deviceServiceIdBinary, }, }, }); } else if (!isPinned && existingIndex !== -1) { newPinnedConversations.splice(existingIndex, 1); } return { account: { ...account, pinnedConversations: newPinnedConversations, }, }; }); } changeGroupPin(group, isPinned) { return this.updateItem((item) => item.isAccount(), (record) => { const { account } = record; const { pinnedConversations } = account; const newPinnedConversations = pinnedConversations?.slice() ?? []; const existingIndex = newPinnedConversations.findIndex((convo) => { if (convo.identifier?.groupMasterKey == null) { return false; } return group.masterKey.equals(convo.identifier.groupMasterKey); }); if (isPinned && existingIndex === -1) { newPinnedConversations.push({ identifier: { groupMasterKey: group.masterKey, }, }); } else if (!isPinned && existingIndex !== -1) { newPinnedConversations.splice(existingIndex, 1); } return { account: { ...account, pinnedConversations: newPinnedConversations, }, }; }); } static createStorageID() { return crypto_1.default.randomBytes(KEY_SIZE); } } exports.StorageState = StorageState;