import { EventEmitter } from 'eventemitter3'; import type { Contact, Focus } from './api'; import { Logging } from './logging'; import { FocusModule } from './focus'; import type { Card, Channel, Article, Topic, Asset, Tag, FocusDetail, Profile, Participant } from './types'; import { type CardEntity, avatar } from './entities'; import type { ArticleDetail, ChannelSummary, ChannelDetail, CardProfile, CardDetail, CardItem, ChannelItem, ArticleItem } from './items'; import { defaultCardItem, defaultChannelItem } from './items'; import { Store } from './store'; import { Crypto } from './crypto'; import { Staging } from './staging'; import { getCards } from './net/getCards'; import { getCardProfile } from './net/getCardProfile'; import { getCardDetail } from './net/getCardDetail'; import { setCardProfile } from './net/setCardProfile'; import { getContactProfile } from './net/getContactProfile'; import { getContactChannels } from './net/getContactChannels'; import { getContactChannelDetail } from './net/getContactChannelDetail'; import { getContactChannelSummary } from './net/getContactChannelSummary'; import { getCardImageUrl } from './net/getCardImageUrl'; import { addCard } from './net/addCard'; import { removeCard } from './net/removeCard'; import { getContactListing } from './net/getContactListing'; import { setCardConfirmed } from './net/setCardConfirmed'; import { setCardConnecting } from './net/setCardConnecting'; import { setCardConnected } from './net/setCardConnected'; import { removeContactChannel } from './net/removeContactChannel'; import { getContactChannelNotifications } from './net/getContactChannelNotifications'; import { setContactChannelNotifications } from './net/setContactChannelNotifications'; import { getRegistryImageUrl } from './net/getRegistryImageUrl'; import { getRegistryListing } from './net/getRegistryListing'; import { addFlag } from './net/addFlag'; import { getCardOpenMessage } from './net/getCardOpenMessage'; import { setCardOpenMessage } from './net/setCardOpenMessage'; import { getCardCloseMessage } from './net/getCardCloseMessage'; import { setCardCloseMessage } from './net/setCardCloseMessage'; import { getLegacyData } from './legacy'; const CLOSE_POLL_MS = 100; const RETRY_POLL_MS = 2000; export class ContactModule implements Contact { private log: Logging; private guid: string; private token: string; private node: string; private secure: boolean; private emitter: EventEmitter; private articleTypes: string[]; private channelTypes: string[]; private seal: { privateKey: string; publicKey: string } | null; private unsealAll: boolean; private focus: FocusModule | null; private crypto: Crypto | null; private staging: Staging | null; private store: Store; private revision: number; private nextRevision: number | null; private syncing: boolean; private closing: boolean; private hasSynced: boolean; // set of markers private blockedCard: Set; private blockedCardChannel: Set; private read: Map; // set of cards to resync private resync: Set; // view of cards private cardEntries: Map; // view of articles private articleEntries: Map>; // view of channels private channelEntries: Map>; constructor(log: Logging, store: Store, crypto: Crypto | null, staging: Staging | null, guid: string, token: string, node: string, secure: boolean, articleTypes: string[], channelTypes: string[]) { this.guid = guid; this.token = token; this.node = node; this.secure = secure; this.log = log; this.store = store; this.crypto = crypto; this.staging = staging; this.emitter = new EventEmitter(); this.articleTypes = articleTypes; this.channelTypes = channelTypes; this.unsealAll = false; this.focus = null; this.seal = null; this.cardEntries = new Map(); this.articleEntries = new Map>(); this.channelEntries = new Map>(); this.resync = new Set(); this.blockedCard = new Set(); this.blockedCardChannel = new Set(); this.read = new Map(); this.revision = 0; this.syncing = true; this.closing = false; this.nextRevision = null; this.hasSynced = false; this.init(); } private async init() { const { guid } = this; this.revision = await this.store.getContactRevision(guid); const blockedCardMarkers = await this.store.getMarkers(guid, 'blocked_card'); blockedCardMarkers.forEach((marker) => { this.blockedCard.add(marker.id); }); const blockedCardChannelMarkers = await this.store.getMarkers(guid, 'blocked_card_channel'); blockedCardChannelMarkers.forEach((marker) => { this.blockedCardChannel.add(marker.id); }); const readMarkers = await this.store.getMarkers(guid, 'read_card_channel'); readMarkers.forEach((marker) => { this.read.set(marker.id, parseInt(marker.value)); }); const hasSyncedMarkers = await this.store.getMarkers(guid, 'first_sync_complete'); this.hasSynced = hasSyncedMarkers.filter((marker) => (marker.id === 'contact')).length !== 0; // load map of articles const articles = await this.store.getContactCardArticles(guid); articles.forEach(({ cardId, articleId, item }) => { const articles = this.articleEntries.get(cardId); const article = this.setArticle(cardId, articleId, item); if (!articles) { const entries = new Map(); this.articleEntries.set(cardId, entries); entries.set(articleId, { item, article }); } else { articles.set(articleId, { item, article }); } }); // load map of channels const channels = await this.store.getContactCardChannels(guid); channels.forEach(({ cardId, channelId, item }) => { const channels = this.channelEntries.get(cardId); const channel = this.setChannel(cardId, channelId, item); if (!channels) { const entries = new Map(); this.channelEntries.set(cardId, entries); entries.set(channelId, { item, channel }); } else { channels.set(channelId, { item, channel }); } }); // load map of cards const cards = await this.store.getContacts(guid); cards.forEach(({ cardId, item }) => { const card = this.setCard(cardId, item); this.cardEntries.set(cardId, { item, card }); this.emitArticles(cardId); this.emitChannels(cardId); }); this.emitCards(); this.unsealAll = true; this.syncing = false; await this.sync(); } public async setRevision(rev: number): Promise { this.nextRevision = rev; await this.sync(); } public async close() { this.closing = true; if (this.focus) { await this.focus.close(); this.focus = null; } while (this.syncing) { await new Promise((r) => setTimeout(r, CLOSE_POLL_MS)); } } private isCardBlocked(cardId: string): boolean { return this.blockedCard.has(cardId); } private async setCardBlocked(cardId: string) { const entry = this.cardEntries.get(cardId); if (!entry) { throw new Error('card not found'); } this.blockedCard.add(cardId); entry.card = this.setCard(cardId, entry.item); this.emitCards(); const timestamp = Math.floor(Date.now() / 1000); await this.store.setMarker(this.guid, 'blocked_card', cardId, JSON.stringify({cardId, timestamp})); } private async clearCardBlocked(cardId: string) { const entry = this.cardEntries.get(cardId); if (!entry) { throw new Error('card not found'); } this.blockedCard.delete(cardId); entry.card = this.setCard(cardId, entry.item); this.emitCards(); await this.store.clearMarker(this.guid, 'blocked_card', cardId); } private isChannelBlocked(cardId: string, channelId: string): boolean { const id = `${cardId}:${channelId}`; return this.blockedCardChannel.has(id); } private async setChannelBlocked(cardId: string, channelId: string) { const channelsEntry = this.channelEntries.get(cardId); if (!channelsEntry) { throw new Error('card not found'); } const channelEntry = channelsEntry.get(channelId); if (!channelEntry) { throw new Error('channel not found'); } const id = `${cardId}:${channelId}`; this.blockedCardChannel.add(id); channelEntry.channel = this.setChannel(cardId, channelId, channelEntry.item); this.emitChannels(cardId); const timestamp = Math.floor(Date.now() / 1000); await this.store.setMarker(this.guid, 'blocked_card_channel', id, JSON.stringify({ cardId, channelId, timestamp })); } private async clearChannelBlocked(cardId: string, channelId: string) { const channelsEntry = this.channelEntries.get(cardId); if (!channelsEntry) { throw new Error('card not found'); } const channelEntry = channelsEntry.get(channelId); if (!channelEntry) { throw new Error('channel not found'); } const id = `${cardId}:${channelId}`; this.blockedCardChannel.delete(id); channelEntry.channel = this.setChannel(cardId, channelId, channelEntry.item); this.emitChannels(cardId); await this.store.clearMarker(this.guid, 'blocked_card_channel', id); } private isArticleBlocked(cardId: string, articleId: string): boolean { return false; } private async setArticleBlocked(cardId: string, articleId: string) { } private async clearArticleBlocked(cardId: string, articleId: string) { } private isChannelUnread(cardId: string, channelId: string, revision: number): boolean { const id = `${cardId}:${channelId}`; if (this.read.has(id)) { const read = this.read.get(id); if (read && read >= revision) { return false; } } return true; } private async markChannelUnread(cardId: string, channelId: string, revision: number) { const id = `${cardId}:${channelId}`; if (!this.read.has(id)) { const read = this.read.get(id); if (read && read < revision) { this.read.delete(id); await this.store.clearMarker(this.guid, 'read_card_channel', id); } } } private async markChannelRead(cardId: string, channelId: string, revision: number) { const id = `${cardId}:${channelId}`; const read = this.read.get(id); if (!read || read < revision) { this.read.set(id, revision); await this.store.setMarker(this.guid, 'read_card_channel', id, revision.toString()); } } public async resyncCard(cardId: string): Promise { this.resync.add(cardId); await this.sync(); } private async sync(): Promise { if (!this.syncing) { this.syncing = true; const { guid, node, secure, token } = this; while ((this.unsealAll || this.nextRevision || this.resync.size) && !this.closing) { if (this.resync.size) { const entries = Array.from(this.cardEntries, ([key, value]) => ({ key, value })); for (const entry of entries) { const { key, value } = entry; if (this.resync.has(key)) { if (value.item.offsyncProfile) { try { const { profile, detail, profileRevision, offsyncProfile } = value.item; await this.syncProfile(key, profile.node, profile.guid, detail.token, profileRevision); value.item.profileRevision = offsyncProfile; await this.store.setContactCardProfileRevision(guid, key, profileRevision); value.item.offsyncProfile = null; await this.store.clearContactCardOffsyncProfile(guid, key); entry.value.card = this.setCard(key, entry.value.item); this.emitCards(); } catch (err) { this.log.warn(err); } } if (value.item.offsyncArticle) { try { const { profile, detail, articleRevision, offsyncArticle } = value.item; await this.syncArticles(key, profile.node, profile.guid, detail.token, articleRevision); value.item.articleRevision = offsyncArticle; await this.store.setContactCardArticleRevision(guid, key, articleRevision); value.item.offsyncArticle = null; await this.store.clearContactCardOffsyncArticle(guid, key); entry.value.card = this.setCard(key, entry.value.item); this.emitCards(); } catch (err) { this.log.warn(err); } } if (value.item.offsyncChannel) { try { const { profile, detail, channelRevision, offsyncChannel } = value.item; await this.syncChannels(key, { guid: profile.guid, node: profile.node, token: detail.token }, channelRevision); value.item.channelRevision = offsyncChannel; await this.store.setContactCardChannelRevision(guid, key, value.item.channelRevision); value.item.offsyncChannel = null; await this.store.clearContactCardOffsyncChannel(guid, key); entry.value.card = this.setCard(key, entry.value.item); this.emitCards(); } catch (err) { this.log.warn(err); } } } } this.resync.clear(); } if (this.nextRevision && this.revision !== this.nextRevision) { const nextRev = this.nextRevision; try { const delta = await getCards(node, secure, token, this.revision); for (const entity of delta) { const { id, revision, data } = entity; if (data) { const entry = await this.getCardEntry(id); if (data.detailRevision !== entry.item.detail.revison) { const detail = data.cardDetail ? data.cardDetail : await getCardDetail(node, secure, token, id); const { status, statusUpdated, token: cardToken } = detail; entry.item.detail = { revision: data.detailRevision, status, statusUpdated, token: cardToken, }; entry.card = this.setCard(id, entry.item); await this.store.setContactCardDetail(guid, id, entry.item.detail); } if (data.profileRevision !== entry.item.profile.revision) { const profile = data.cardProfile ? data.cardProfile : await getCardProfile(node, secure, token, id); entry.item.profile = { revision: data.profileRevision, handle: profile.handle, guid: profile.guid, name: profile.name, description: profile.description, location: profile.location, imageSet: profile.imageSet, node: profile.node, seal: profile.seal, }; entry.card = this.setCard(id, entry.item); await this.store.setContactCardProfile(guid, id, entry.item.profile); } const { profileRevision, articleRevision, channelRevision, offsyncProfile, offsyncChannel, offsyncArticle } = entry.item; if (data.notifiedProfile > entry.item.profile.revision && data.notifiedProfile !== profileRevision) { if (offsyncProfile) { entry.item.offsyncProfile = data.notifiedProfile; await this.store.setContactCardOffsyncProfile(guid, id, data.notifiedProfile); entry.card = this.setCard(id, entry.item); } else { try { await this.syncProfile(id, entry.item.profile.node, entry.item.profile.guid, entry.item.detail.token, entry.item.profileRevision); entry.item.profileRevision = data.notifiedProfile; await this.store.setContactCardProfileRevision(guid, id, data.notifiedProfile); } catch (err) { this.log.warn(err); entry.item.offsyncProfile = data.notifiedProfile; await this.store.setContactCardOffsyncProfile(guid, id, data.notifiedProfile); entry.card = this.setCard(id, entry.item); } } } if (data.notifiedArticle !== articleRevision) { if (offsyncArticle) { entry.item.offsyncArticle = data.notifiedArticle; await this.store.setContactCardOffsyncArticle(guid, id, data.notifiedArticle); entry.card = this.setCard(id, entry.item); } else { try { await this.syncArticles(id, entry.item.profile.node, entry.item.profile.guid, entry.item.detail.token, entry.item.articleRevision); entry.item.articleRevision = data.notifiedArticle; await this.store.setContactCardArticleRevision(guid, id, data.notifiedArticle); this.emitArticles(id); } catch (err) { this.log.warn(err); entry.item.offsyncArticle = data.notifiedArticle; await this.store.setContactCardOffsyncArticle(guid, id, data.notifiedArticle); entry.card = this.setCard(id, entry.item); } } } if (data.notifiedChannel !== channelRevision) { if (offsyncChannel) { entry.item.offsyncChannel = data.notifiedChannel; await this.store.setContactCardOffsyncChannel(guid, id, data.notifiedChannel); entry.card = this.setCard(id, entry.item); } else { try { const { profile, detail } = entry.item; await this.syncChannels(id, { guid: profile.guid, node: profile.node, token: detail.token }, entry.item.channelRevision); entry.item.channelRevision = data.notifiedChannel; await this.store.setContactCardChannelRevision(guid, id, data.notifiedChannel); this.emitChannels(id); } catch (err) { this.log.warn(err); entry.item.offsyncChannel = data.notifiedChannel; await this.store.setContactCardOffsyncChannel(guid, id, data.notifiedChannel); entry.card = this.setCard(id, entry.item); } } } } else { this.cardEntries.delete(id); await this.store.removeContactCard(guid, id); this.channelEntries.delete(id); this.emitChannels(id); this.articleEntries.delete(id); this.emitArticles(id); } } this.emitCards(); await this.store.setContactRevision(guid, nextRev); this.revision = nextRev; if (this.nextRevision === nextRev) { this.nextRevision = null; } this.log.info(`card revision: ${nextRev}`); } catch (err) { this.log.warn(err); await new Promise((r) => setTimeout(r, RETRY_POLL_MS)); } } if (this.revision === this.nextRevision) { this.nextRevision = null; } if (this.unsealAll) { for (const [cardId, channels] of this.channelEntries.entries()) { for (const [channelId, entry] of channels.entries()) { try { const { item } = entry; if (await this.unsealChannelDetail(cardId, channelId, item)) { await this.store.setContactCardChannelUnsealedDetail(guid, cardId, channelId, item.unsealedDetail); } if (await this.unsealChannelSummary(cardId, channelId, item)) { await this.store.setContactCardChannelUnsealedSummary(guid, cardId, channelId, item.unsealedSummary); } entry.channel = this.setChannel(cardId, channelId, item); } catch (err) { this.log.warn(err); } } this.emitChannels(cardId); } this.unsealAll = false; } } if (this.revision && !this.hasSynced) { this.hasSynced = true; await this.store.setMarker(this.guid, 'first_sync_complete', 'contact', ''); } this.syncing = false; } } private async syncProfile(cardId: string, cardNode: string, cardGuid: string, cardToken: string, revision: number): Promise { const { node, secure, token } = this; const server = cardNode ? cardNode : node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); const message = await getContactProfile(server, !insecure, cardGuid, cardToken); await setCardProfile(node, secure, token, cardId, message); } private async syncArticles(cardId: string, cardNode: string, cardGuid: string, cardToken: string, revision: number): Promise { const { node, secure, token } = this; const server = cardNode ? cardNode : node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); } private async syncChannels(cardId: string, card: { guid: string; node: string; token: string }, revision: number): Promise { const { guid, node, secure, token, channelTypes } = this; const server = card.node ? card.node : node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); const delta = await getContactChannels(server, !insecure, card.guid, card.token, revision, channelTypes); for (const entity of delta) { const { id, revision, data } = entity; if (data) { const { detailRevision, topicRevision, channelSummary, channelDetail } = data; const entries = this.getChannelEntries(cardId); const entry = await this.getChannelEntry(entries, cardId, id); if (detailRevision !== entry.item.detail.revision) { const detail = channelDetail ? channelDetail : await getContactChannelDetail(server, !insecure, card.guid, card.token, id); entry.item.detail = { revision: detailRevision, sealed: detail.dataType === 'sealed', dataType: detail.dataType, data: detail.data, created: detail.created, updated: detail.updated, enableImage: detail.enableImage, enableAudio: detail.enableAudio, enableVideo: detail.enableVideo, enableBinary: detail.enableBinary, contacts: detail.contacts, members: detail.members, }; entry.item.unsealedDetail = null; await this.unsealChannelDetail(cardId, id, entry.item); if (this.focus) { const { dataType, data, enableImage, enableAudio, enableVideo, enableBinary, members, created } = detail; const sealed = dataType === 'sealed'; const channelData = sealed ? entry.item.unsealedDetail : data; const focusDetail = { sealed, locked: sealed && (!this.seal || !entry.item.channelKey), dataType, data: this.parse(channelData), enableImage, enableAudio, enableVideo, enableBinary, created, members: members.map(guid => ({ guid })), } this.focus.setDetail(cardId, id, focusDetail); } entry.channel = this.setChannel(cardId, id, entry.item); await this.store.setContactCardChannelDetail(guid, cardId, id, entry.item.detail, entry.item.unsealedDetail); } if (topicRevision !== entry.item.summary.revision) { const summary = channelSummary ? channelSummary : await getContactChannelSummary(server, !insecure, card.guid, card.token, id); entry.item.summary = { revision: topicRevision, sealed: summary.lastTopic.dataType === 'sealedtopic', guid: summary.lastTopic.guid, dataType: summary.lastTopic.dataType, data: summary.lastTopic.data, created: summary.lastTopic.created, updated: summary.lastTopic.updated, status: summary.lastTopic.status, transform: summary.lastTopic.transform, }; entry.item.unsealedSummary = null; await this.unsealChannelSummary(cardId, id, entry.item); if (this.hasSynced) { await this.markChannelUnread(cardId, id, topicRevision); } else { await this.markChannelRead(cardId, id, topicRevision); } entry.channel = this.setChannel(cardId, id, entry.item); await this.store.setContactCardChannelSummary(guid, cardId, id, entry.item.summary, entry.item.unsealedSummary); if (this.focus) { await this.focus.setRevision(cardId, id, topicRevision); } } } else { const channels = this.getChannelEntries(cardId); channels.delete(id); if (this.focus) { this.focus.disconnect(cardId, id); } await this.store.removeContactCardChannel(guid, cardId, id); } } } public addCardListener(ev: (cards: Card[]) => void): void { this.emitter.on('card', ev); const cards = Array.from(this.cardEntries, ([cardId, entry]) => entry.card); ev(cards); } public removeCardListener(ev: (cards: Card[]) => void): void { this.emitter.off('card', ev); } private emitCards() { const cards = Array.from(this.cardEntries, ([cardId, entry]) => entry.card); this.emitter.emit('card', cards); } public addArticleListener(id: string | null, ev: (arg: { cardId: string; articles: Article[] }) => void): void { if (id) { const cardId = id as string; this.emitter.on(`article::${cardId}`, ev); const entries = this.articleEntries.get(cardId); const articles = entries ? Array.from(entries, ([articleId, entry]) => entry.article) : []; ev({ cardId, articles }); } else { this.emitter.on('article', ev); this.articleEntries.forEach((entries, cardId) => { const articles = Array.from(entries, ([articleId, entry]) => entry.article); ev({ cardId, articles }); }); } } public removeArticleListener(id: string | null, ev: (arg: { cardId: string; articles: Article[] }) => void): void { if (id) { const cardId = id as string; this.emitter.off(`article::${cardId}`, ev); } else { this.emitter.off('article', ev); } } private emitArticles(cardId: string) { const entries = this.articleEntries.get(cardId); const articles = entries ? Array.from(entries, ([articleId, entry]) => entry.article) : []; this.emitter.emit('article', { cardId, articles }); this.emitter.emit(`article::${cardId}`, { cardId, articles }); } public addChannelListener(ev: (arg: { cardId: string; channels: Channel[] }) => void): void { this.emitter.on('channel', ev); this.channelEntries.forEach((entries, cardId) => { const channels = Array.from(entries, ([channelId, entry]) => entry.channel); ev({ cardId, channels }); }); } public removeChannelListener(ev: (arg: { cardId: string; channels: Channel[] }) => void): void { this.emitter.off('channel', ev); } private emitChannels(cardId: string) { const entries = this.channelEntries.get(cardId); const channels = entries ? Array.from(entries, ([channelId, entry]) => entry.channel) : []; this.emitter.emit('channel', { cardId, channels }); } public async setFocus(cardId: string, channelId: string): Promise { if (this.focus) { this.focus.close(); } const markRead = async () => { try { await this.setUnreadChannel(cardId, channelId, false); } catch (err) { this.log.error(err); } } const flagTopic = async (topicId: string) => { const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : this.node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); await addFlag(server, !insecure, entry.item.profile.guid, { channelId, topicId }); } } const cardEntry = this.cardEntries.get(cardId); const channelsEntry = this.channelEntries.get(cardId); const channelEntry = channelsEntry?.get(channelId); if (cardEntry && channelEntry) { // allocate focus const node = cardEntry.item.profile.node; const guid = cardEntry.item.profile.guid; const token = cardEntry.item.detail.token; const revision = channelEntry.item.summary.revision; const channelKey = await this.setChannelKey(channelEntry.item); const sealEnabled = Boolean(this.seal); const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(node); this.focus = new FocusModule(this.log, this.store, this.crypto, this.staging, cardId, channelId, this.guid, { node, secure: !insecure, token: `${guid}.${token}` }, channelKey, sealEnabled, revision, markRead, flagTopic); // set current detail const { dataType, data, enableImage, enableAudio, enableVideo, enableBinary, members, created } = channelEntry.item.detail; const sealed = dataType === 'sealed'; const channelData = sealed ? channelEntry.item.unsealedDetail : data; const focusDetail = { sealed, locked: sealed && (!this.seal || !channelEntry.item.channelKey), dataType, data: this.parse(channelData), enableImage, enableAudio, enableVideo, enableBinary, created, members: members.map(guid => ({ guid })), } this.focus.setDetail(cardId, channelId, focusDetail); } else { this.focus = new FocusModule(this.log, this.store, this.crypto, this.staging, cardId, channelId, this.guid, null, null, false, 0, markRead, flagTopic); } return this.focus; } public clearFocus() { if (this.focus) { this.focus.close(); this.focus = null; } } public async addCard(server: string | null, guid: string): Promise { const { node, secure, token } = this; const insecure = server ? /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server) : false; const message = server ? await getContactListing(server, !insecure, guid) : await getContactListing(node, secure, guid); const added = await addCard(node, secure, token, message); return added.id; } public async removeCard(cardId: string): Promise { const { node, secure, token } = this; await removeCard(node, secure, token, cardId); } public async confirmCard(cardId: string): Promise { const { node, secure, token } = this; await setCardConfirmed(node, secure, token, cardId); } public async connectCard(cardId: string): Promise { const { node, secure, token } = this; await setCardConnecting(node, secure, token, cardId); try { const message = await getCardOpenMessage(node, secure, token, cardId); const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); const contact = await setCardOpenMessage(server, !insecure, message); if (contact.status === 'connected') { const { token: contactToken, articleRevision, channelRevision, profileRevision } = contact; await setCardConnected(node, secure, token, cardId, contactToken, articleRevision, channelRevision, profileRevision); } } } catch (err) { this.log.error('failed to deliver open message'); } } public async disconnectCard(cardId: string): Promise { const { node, secure, token } = this; await setCardConfirmed(node, secure, token, cardId); try { const message = await getCardCloseMessage(node, secure, token, cardId); const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); await setCardCloseMessage(server, !insecure, message); } } catch (err) { this.log.warn('failed to deliver close message'); } } public async denyCard(cardId: string): Promise { const { node, secure, token } = this; const entry = this.cardEntries.get(cardId); if (entry) { try { const message = await getCardCloseMessage(node, secure, token, cardId); const server = entry.item.profile.node ? entry.item.profile.node : node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); await setCardCloseMessage(server, !insecure, message); } catch (err) { this.log.warn('failed to deliver close message'); } if (entry.item.detail.status === 'pending') { await removeCard(node, secure, token, cardId); } else { await setCardConfirmed(node, secure, token, cardId); } } } public async ignoreCard(cardId: string): Promise { const { node, secure, token } = this; const entry = this.cardEntries.get(cardId); if (entry) { if (entry.item.detail.status === 'pending') { await removeCard(node, secure, token, cardId); } else { await setCardConfirmed(node, secure, token, cardId); } } } public async removeArticle(cardId: string, articleId: string): Promise {} public async leaveChannel(cardId: string, channelId: string): Promise { const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : this.node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); await removeContactChannel(server, !insecure, entry.item.profile.guid, entry.item.detail.token, channelId); } } public async setBlockedCard(cardId: string, blocked: boolean): Promise { const entry = this.cardEntries.get(cardId); if (entry) { if (blocked) { await this.setCardBlocked(cardId); } else { await this.clearCardBlocked(cardId); } entry.card = this.setCard(cardId, entry.item); this.emitCards(); } } public async setBlockedChannel(cardId: string, channelId: string, blocked: boolean): Promise { const entries = this.channelEntries.get(cardId); if (entries) { const entry = entries.get(channelId); if (entry) { if (blocked) { await this.setChannelBlocked(cardId, channelId); } else { await this.clearChannelBlocked(cardId, channelId); } entry.channel = this.setChannel(cardId, channelId, entry.item); this.emitChannels(cardId); } } } public async clearBlockedChannelTopic(cardId: string, channelId: string, topicId: string) { const { guid } = this; const id = `${cardId}:${channelId}:${topicId}` await this.store.clearMarker(guid, 'blocked_topic', id); if (this.focus) { await this.focus.clearBlockedChannelTopic(cardId, channelId, topicId); } } public async setBlockedArticle(cardId: string, articleId: string, blocked: boolean): Promise { const entries = this.articleEntries.get(cardId); if (entries) { const entry = entries.get(articleId); if (entry) { if (blocked) { await this.setArticleBlocked(cardId, articleId); } else { await this.clearArticleBlocked(cardId, articleId); } entry.article = this.setArticle(cardId, articleId, entry.item); this.emitArticles(cardId); } } } public async flagCard(cardId: string): Promise { const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : this.node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); await addFlag(server, !insecure, entry.item.profile.guid, {}); } } public async flagArticle(cardId: string, articleId: string): Promise { const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : this.node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); await addFlag(server, !insecure, entry.item.profile.guid, { articleId }); } } public async flagChannel(cardId: string, channelId: string): Promise { const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : this.node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); await addFlag(server, !insecure, entry.item.profile.guid, { channelId }); } } public async setUnreadChannel(cardId: string, channelId: string, unread: boolean): Promise { const channelsEntry = this.channelEntries.get(cardId); if (!channelsEntry) { throw new Error('card not found'); } const channelEntry = channelsEntry.get(channelId); if (!channelEntry) { throw new Error('channel not found'); } const id = `${cardId}:${channelId}`; if (unread) { this.read.delete(id); channelEntry.channel = this.setChannel(cardId, channelId, channelEntry.item); this.emitChannels(cardId); await this.store.clearMarker(this.guid, 'read_card_channel', id); } else { const revision = channelEntry.item.summary.revision; this.read.set(id, revision); channelEntry.channel = this.setChannel(cardId, channelId, channelEntry.item); this.emitChannels(cardId); await this.store.setMarker(this.guid, 'read_card_channel', id, revision.toString()); } } public async getChannelNotifications(cardId: string, channelId: string): Promise { const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : this.node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); return await getContactChannelNotifications(server, !insecure, entry.item.profile.guid, entry.item.detail.token, channelId); } return false; } public async setChannelNotifications(cardId: string, channelId: string, enabled: boolean): Promise { const entry = this.cardEntries.get(cardId); if (entry) { const server = entry.item.profile.node ? entry.item.profile.node : this.node; const insecure = /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server); await setContactChannelNotifications(server, !insecure, entry.item.profile.guid, entry.item.detail.token, channelId, enabled); } } public async getRegistry(handle: string | null, server: string | null): Promise { const { node, secure } = this; const insecure = server ? /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|:\d+$|$)){4}$/.test(server) : false; const listing = server ? await getRegistryListing(handle, server, !insecure) : await getRegistryListing(handle, node, secure); return listing.map((entity) => { return { guid: entity.guid, handle: entity.handle, name: entity.name, description: entity.description, location: entity.location, node: entity.node, version: entity.version, sealSet: Boolean(entity.seal), imageUrl: entity.imageSet ? (server ? getRegistryImageUrl(server, true, entity.guid) : getRegistryImageUrl(node, secure, entity.guid)) : avatar, imageSet: entity.imageSet, }; }); } private setCard(cardId: string, item: CardItem): Card { const { node, secure, token } = this; const { profile, detail } = item; return { cardId, offsync: Boolean(item.offsyncProfile || item.offsyncChannel || item.offsyncArticle), blocked: this.isCardBlocked(cardId), sealable: Boolean(item.profile.seal), status: detail.status, statusUpdated: detail.statusUpdated, guid: profile.guid, handle: profile.handle, name: profile.name, description: profile.description, location: profile.location, imageUrl: profile.imageSet ? getCardImageUrl(node, secure, token, cardId, item.profile.revision) : avatar, imageSet: profile.imageSet, node: profile.node, version: profile.version, }; } private setArticle(cardId: string, articleId: string, item: ArticleItem): Article { const { detail } = item; const articleData = detail.sealed ? item.unsealedDetail : detail.data; return { cardId, articleId, sealed: detail.sealed, blocked: this.isArticleBlocked(cardId, articleId), dataType: detail.dataType, data: articleData, created: detail.created, updated: detail.updated, }; } private setChannel(cardId: string, channelId: string, item: ChannelItem): Channel { const { summary, detail } = item; const channelData = detail.sealed ? item.unsealedDetail : detail.data || '{}'; const topicData = summary.sealed ? item.unsealedSummary : summary.data || '{}'; const parsed = this.parse(topicData); return { channelId, cardId, lastTopic: { guid: summary.guid, sealed: summary.sealed, dataType: summary.dataType, data: getLegacyData(parsed).data, created: summary.created, updated: summary.updated, status: summary.status, transform: summary.transform, }, blocked: this.isChannelBlocked(cardId, channelId), unread: this.isChannelUnread(cardId, channelId, summary.revision), sealed: detail.sealed, locked: detail.sealed && (!this.seal || !item.channelKey), dataType: detail.dataType, data: this.parse(channelData), created: detail.created, updated: detail.updated, enableImage: detail.enableImage, enableAudio: detail.enableAudio, enableVideo: detail.enableVideo, enableBinary: detail.enableBinary, members: detail.members.map((guid) => ({ guid })), }; } public async setSeal(seal: { privateKey: string; publicKey: string } | null) { this.seal = seal; if (this.focus) { await this.focus.setSealEnabled(Boolean(this.seal)); } this.unsealAll = true; await this.sync(); } public async getSeal(cardId: string, keyData: string): Promise<{ publicKey: string; sealedKey: string }> { if (!this.crypto) { throw new Error('crypto not set'); } const card = this.cardEntries.get(cardId); if (!card) { throw new Error('specified card not found'); } const publicKey = card.item.profile.seal; if (!publicKey) { throw new Error('seal key not set for card'); } const sealed = await this.crypto.rsaEncrypt(keyData, publicKey); const sealedKey = sealed.encryptedDataB64; return { publicKey, sealedKey }; } private async getChannelKey(seals: [{ publicKey: string; sealedKey: string }]): Promise { const seal = seals.find(({ publicKey }) => this.seal && publicKey === this.seal.publicKey); if (seal && this.crypto && this.seal) { const key = await this.crypto.rsaDecrypt(seal.sealedKey, this.seal.privateKey); return key.data; } return null; } private async setChannelKey(item: ChannelItem) { if (!item.channelKey && item.detail.dataType === 'sealed' && this.seal && this.crypto) { try { const { seals } = JSON.parse(item.detail.data); item.channelKey = await this.getChannelKey(seals); } catch (err) { console.log(err); } } return item.channelKey; } private async unsealChannelDetail(cardId: string, channelId: string, item: ChannelItem): Promise { if (item.unsealedDetail == null && item.detail.dataType === 'sealed' && this.seal && this.crypto) { try { const { subjectEncrypted, subjectIv, seals } = JSON.parse(item.detail.data); if (!item.channelKey) { item.channelKey = await this.getChannelKey(seals); if (this.focus) { try { await this.focus.setChannelKey(cardId, channelId, item.channelKey); } catch (err) { this.log.warn(err); } } } if (item.channelKey) { const { data } = await this.crypto.aesDecrypt(subjectEncrypted, subjectIv, item.channelKey); item.unsealedDetail = data; if (this.focus) { const { dataType, enableImage, enableAudio, enableVideo, enableBinary, members, created } = item.detail; const focusDetail = { sealed: true, locked: false, dataType, data: this.parse(data), enableImage, enableAudio, enableVideo, enableBinary, created, members: members.map(guid => ({ guid })), } this.focus.setDetail(cardId, channelId, focusDetail); } return true; } } catch (err) { this.log.warn(err); } } return false; } private async unsealChannelSummary(cardId: string, channelId: string, item: ChannelItem): Promise { if (item.unsealedSummary == null && item.summary.dataType === 'sealedtopic' && this.seal && this.crypto) { try { if (!item.channelKey) { const { seals } = JSON.parse(item.detail.data); item.channelKey = await this.getChannelKey(seals); if (this.focus) { try { await this.focus.setChannelKey(cardId, channelId, item.channelKey); } catch (err) { this.log.warn(err); } } } if (item.channelKey) { const { messageEncrypted, messageIv } = JSON.parse(item.summary.data); if (!messageEncrypted || !messageIv) { this.log.warn('invalid sealed summary'); } else { const { data } = await this.crypto.aesDecrypt(messageEncrypted, messageIv, item.channelKey); item.unsealedSummary = data; return true; } } } catch (err) { this.log.warn(err); } } return false; } private async getCardEntry(cardId: string) { const { guid } = this; const entry = this.cardEntries.get(cardId); if (entry) { return entry; } const item = JSON.parse(JSON.stringify(defaultCardItem)); const card = this.setCard(cardId, item); const cardEntry = { item, card }; this.cardEntries.set(cardId, cardEntry); await this.store.addContactCard(guid, cardId, item); return cardEntry; } private getChannelEntries(cardId: string) { const entries = this.channelEntries.get(cardId); if (entries) { return entries; } const channels = new Map(); this.channelEntries.set(cardId, channels); return channels; } private async getChannelEntry(channels: Map, cardId: string, channelId: string) { const { guid } = this; const entry = channels.get(channelId); if (entry) { return entry; } const item = JSON.parse(JSON.stringify(defaultChannelItem)); const channel = this.setChannel(cardId, channelId, item); const channelEntry = { item, channel }; channels.set(channelId, channelEntry); await this.store.addContactCardChannel(guid, cardId, channelId, item); return channelEntry; } private parse(data: string | null): any { if (data) { try { if (data == null) { return null; } return JSON.parse(data); } catch (err) { this.log.error('invalid contact data'); } } return {}; } }