| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- 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<string> {
- 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<number> {
- 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<ContactItem> {
- 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<ContactItem> {
- 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<ContactItem[]> {
- 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<ContactBook> {
- 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<ContactItem> {
- 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<ContentItem> {
- 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<any[]> {
- 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<any>) {
- 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;
- }
- }
|