import { IKeyPair } from './key-pair'; import WebSocket from 'ws'; import { ContactAddress } from './contact-address'; import { ContactBook } from './contact-book'; import { ContactItem } from './contact-item'; import { ContentItem } from './content-item'; import { ContentParams } from './content-params'; import { ICrypto } from './crypto'; import { Storage } from './storage'; import { UploadItemParameters } from './upload-item-parameters'; import { encodeHex, mergeDeep, uuid } from './util'; import { IWebClient } from './webclient'; export class BankClient { public static parseBankLink(bankLink: string) { if (!bankLink.startsWith('bank:')) { throw new Error('address must start with bank:'); } const deprefixed = bankLink.substring(5); let host: string | undefined; let address: string; let topic: string; if (deprefixed[0] === '/' && deprefixed[1] === '/') { [ host, address, topic ] = deprefixed.substring(2).split('/'); } else { [ address, topic ] = deprefixed.split('/'); } if (!address || !topic) { throw new Error('cannot parse address and topic'); } return { host, address, topic }; } private privateKey: IKeyPair | undefined; private wsUrlBase: string; private bootstrapPromise: any; private bootstrapResult: any; constructor(private urlBase: string, private ipfsUrlBase: string, private storage: Storage, private webClient: IWebClient, private crypto: ICrypto) { this.wsUrlBase = urlBase.replace(/^http/i, 'ws'); } public getPub(): Promise { return new Promise(async (resolve, reject) => { await this.bootstrap(); try { if (!this.privateKey) { throw new Error('missing privateKey'); } resolve(this.privateKey.getPublicHash()); } catch (e) { reject(e); } }); } public bootstrap() { if (this.bootstrapResult) { return Promise.resolve(this.bootstrapResult); } if (this.bootstrapPromise) { return this.bootstrapPromise; } return this.bootstrapPromise = new Promise((resolve, reject) => { this.storage.get('notaprivatekey').then(async (privateKeyFromStorage) => { if (privateKeyFromStorage == null) { console.log('no private key in storage. generating new'); const privateKey = await this.crypto.generateRsaKeyPair(2048); const exportResult = await privateKey.export(); this.storage.set('notaprivatekey', exportResult).then(err => { // whatever }).catch(reject); this.privateKey = privateKey; resolve(true); } else { // console.log('importing privatekey'); this.privateKey = await this.crypto.importRsaKeyPair(privateKeyFromStorage); resolve(true); } }).catch(reject); }); } public async getNonce() { const nonce = await this.webClient.request({ method: 'GET', url: this.urlBase + '/bank/nonce' }); return Number(nonce); } public async getBalance(): Promise { const nonce = await this.getNonce(); const retrieveRequest = await this.makePlaintextPayload(JSON.stringify({ _date: new Date().toISOString(), _nonce: nonce })); const topicURL = this.urlBase + '/bank/getbalance'; const postResponse:any = await this.webClient.requestJSON({ body: retrieveRequest, method: 'POST', url: topicURL }); return postResponse.balance; } public async upload(params: UploadItemParameters) { const url = this.urlBase + '/bank/upload'; const formData: any = {}; formData.creator = await this.getPub(); if (params.fileData) { formData.file = { value: params.fileData, options: { filename: params.fileName } }; } if (params.thumbFileData) { formData.thumb = { value: params.thumbFileData, options: { filename: params.thumbFileName } }; } if (params.links) { formData.links = JSON.stringify(params.links); } for (const attr of ['title', 'text', 'type', 'dmMe']) { if (params[attr] != null) { formData[attr] = '' + params[attr]; } } // console.log('formData', formData); const uploadResponse: any = await this.webClient.requestJSON({ formData, method: 'POST', url }); // console.log('uploadResponse', uploadResponse); return uploadResponse.hash; } public async uploadSlimJSON(item: any) { const url = this.urlBase + '/bank/upload/slim'; const uploadResponse: any = await this.webClient.requestJSON({ body: item, method: 'POST', url }); // console.log('uploadResponse', uploadResponse); return uploadResponse.hash; } public async uploadSlimText(item: string) { const url = this.urlBase + '/bank/upload/slim'; const uploadResponse: any = JSON.parse(await this.webClient.request({ body: item, headers: { 'content-type': 'text/plain' }, method: 'POST', url })); // console.log('uploadResponse', uploadResponse); return uploadResponse.hash; } public async appendPrivate(peerAddr: string, topic: string, hash?: string, replaceHash?: string, deleteHash?: string, onlyHash?: string) { const nonce = await this.getNonce(); const payload = await this.makePlaintextPayload(JSON.stringify({ _date: new Date().toISOString(), _nonce: nonce, deleteHash, hash, onlyHash, replaceHash, })); const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(peerAddr) + '/' + encodeURIComponent(topic); const result = await this.webClient.request({ body: JSON.stringify(payload), headers: { 'content-type': 'application/json' }, method: 'PUT', url: topicURL }); console.log('appended to ', peerAddr, topic, hash, replaceHash, deleteHash, result); } public async retrievePrivate(peerAddr: string, topic: string) { const nonce = await this.getNonce(); const retrieveRequest = await this.makePlaintextPayload(JSON.stringify({ _date: new Date().toISOString(), _nonce: nonce })); const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(peerAddr) + '/' + encodeURIComponent(topic); const result = await this.webClient.request({ body: JSON.stringify(retrieveRequest), headers: { 'content-type': 'application/json' }, method: 'POST', url: topicURL }); return result; } public async subscribePrivate(peerAddr: string, topic: string, connectCallback: () => void, messageCallback: (data: any) => void) { await this.connectWebsocket(peerAddr, topic, connectCallback, messageCallback); } public async getOrCreateContact(peerId: string, addressType: string, addressValue: string, contactBook?: ContactBook): Promise { if (contactBook == null) { console.log('warning: inefficient'); contactBook = await this.getContactBook(peerId); } const existing = contactBook.lookupByAddress(addressType, addressValue); if (existing != null) { return existing; } console.log('creating new contact', peerId, addressType, addressValue); return await this.createContact(peerId, addressType, addressValue); } public async createContact(peerId: string, addressType?: string, addressValue?: string): Promise { const contactId = uuid(); const newItem:any = { addrs: [ ], id: contactId }; if (addressType != null && addressValue != null) { newItem.addrs.push(new ContactAddress(addressType, addressValue).toPrefixedString()); } const newItemHash = await this.uploadSlimJSON(newItem); await this.appendPrivate(peerId, '📇', newItemHash); const contactBook2 = await this.getContactBook(peerId); return (await contactBook2.lookupById(contactId)) as ContactItem; } public async getAllContacts(peerId: string): Promise { const contactList = await this.retrievePrivate(peerId, '📇'); const items = await this.getItemsForCommaList(contactList); return items.map(data => new ContactItem(data)); } public async getContactBook(peerId: string): Promise { if (peerId == null) { throw new Error('Missing peerId'); } return new ContactBook(await this.getAllContacts(peerId)); } public async updateContact(peerId: string, contactId: string, newProperties: any): Promise { const contactBook = await this.getContactBook(peerId); const existing = await contactBook.lookupById(contactId); if (!existing) { throw new Error('missing contact with id ' + contactId); } const existingData = existing.getData(); const newProps: any = mergeDeep({}, newProperties); delete newProps.id; const newItem: any = mergeDeep(existingData, newProps); delete newItem.hash; newItem.lastChanged = new Date().toISOString(); const newItemHash = await this.uploadSlimJSON(newItem); await this.appendPrivate(peerId, '📇', newItemHash, existing.hash); const contactBook2 = await this.getContactBook(peerId); return (await contactBook2.lookupById(contactId)) as ContactItem; } public async getContentItemByHash(hashInPlaylist: string): Promise { const hash = this.parseItemHash(hashInPlaylist).hash; const contentParams = (await this.webClient.requestJSON({ method: 'get', url: this.ipfsUrlBase + '/ipfs/' + hash + '/content.json' })) as ContentParams; return new ContentItem(hashInPlaylist, hash, contentParams); } public async getItemsForCommaList(commaList: string): Promise { const itemHashes = commaList.split(',').filter(x => x.trim() !== ''); const items: any[] = await Promise.all(itemHashes.map(itemId => { const itemHash = this.parseItemHash(itemId).hash; return this.webClient.requestJSON({ method: 'get', url: this.ipfsUrlBase + '/ipfs/' + itemHash, }); })); for (const item of items) { item.hash = itemHashes.shift(); } return items; } public parseItemHash(itemHash: string) { let type: string | null = null; let timestamp: string | null = null; let hash: string | null = null; if (itemHash.startsWith('/ipfs/')) { itemHash = itemHash.substring(6); } const matched = itemHash.match(/^([0-9]*)_(..)_(.*)$/); if (matched) { timestamp = matched[1]; type = matched[2]; hash = matched[3]; } if (!type) { type = 'CO'; } if (!hash) { hash = itemHash; } return { type, timestamp, hash }; } public async runAgent(address: string, topic: string, storage: Storage, itemProcessCallback: (item: ContentItem | ContactItem) => Promise) { await this.subscribePrivate( address, topic, () => { // console.log('websocket connected'); }, async () => { await agentUpdate(); } ); const agentUpdate = async () => { const agentConfig = (await storage.get('config')) || {}; const items = await this.retrievePrivate(address, topic); const itemsList = items.split(',').filter((x) => x.trim() !== ''); console.log('itemsList', itemsList); for (const itemId of itemsList) { const processed = agentConfig.processed || []; const failed = agentConfig.failed || []; if (processed.includes(itemId) || failed.includes(itemId)) { continue; } try { const item = await this.getContentItemByHash(itemId); console.log('gotItem', item); await itemProcessCallback(item); processed.push(itemId); agentConfig.processed = processed; await storage.set('config', agentConfig); } catch (e) { console.error('error processing item', itemId, e); failed.push(itemId); agentConfig.failed = failed; await storage.set('config', agentConfig); } } } await agentUpdate(); } private connectWebsocket(peerAddr: string, topic: string, connectCallback: () => void, messageCallback: (data: any) => void) { return new Promise(async (resolve, reject) => { const nonce = await this.getNonce(); const retrieveRequest: any = await this.makePlaintextPayload(JSON.stringify({ _date: new Date().toISOString(), _nonce: nonce, addr: peerAddr, topic })); const jsonOutput = JSON.stringify(retrieveRequest); const base64ed = Buffer.from(jsonOutput).toString('base64'); const encoded = encodeURIComponent(base64ed); const ws = new WebSocket(this.wsUrlBase + '/bank/ws?arg=' + encoded); ws.on('open', () => { connectCallback(); }); ws.on('message', data => { messageCallback(data); }); const reconnect = () => { // console.log('reconnect'); try { ws.terminate(); } finally { console.log('reconnecting in 5s'); setTimeout(async () => { try { await this.connectWebsocket(peerAddr, topic, connectCallback, messageCallback); } catch (e) { console.error('error reconnecting', e); } }, 5000); } }; ws.on('error', err => { console.error('websocket error', err); }); ws.on('close', err => { reconnect(); }); resolve(); }); } private async makePlaintextPayload(message: string) { const messageBytes = Buffer.from(message, 'utf-8'); await this.bootstrap(); if (!this.privateKey) { throw new Error('missing privateKey'); } const signatureBytes = await this.privateKey.sign(messageBytes); const publicKey = await this.privateKey.getPublicKey(); const pubHash = await this.privateKey.getPublicHash(); const result = { date: new Date().toISOString(), msg: encodeHex(messageBytes), pub: encodeHex(Buffer.from(publicKey, 'hex')), pubHash, sig: encodeHex(Buffer.from(signatureBytes, 'hex')), }; return result; } }