index.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import { IKeyPair } from './key-pair';
  2. import WebSocket from 'ws';
  3. import { ContactAddress } from './contact-address';
  4. import { ContactBook } from './contact-book';
  5. import { ContactItem } from './contact-item';
  6. import { ContentItem } from './content-item';
  7. import { ContentParams } from './content-params';
  8. import { ICrypto } from './crypto';
  9. import { Storage } from './storage';
  10. import { UploadItemParameters } from './upload-item-parameters';
  11. import { encodeHex, mergeDeep, uuid } from './util';
  12. import { IWebClient } from './webclient';
  13. export class BankClient {
  14. public static parseBankLink(bankLink: string) {
  15. if (!bankLink.startsWith('bank:')) {
  16. throw new Error('address must start with bank:');
  17. }
  18. const deprefixed = bankLink.substring(5);
  19. let host: string | undefined;
  20. let address: string;
  21. let topic: string;
  22. if (deprefixed[0] === '/' && deprefixed[1] === '/') {
  23. [ host, address, topic ] = deprefixed.substring(2).split('/');
  24. } else {
  25. [ address, topic ] = deprefixed.split('/');
  26. }
  27. if (!address || !topic) {
  28. throw new Error('cannot parse address and topic');
  29. }
  30. return { host, address, topic };
  31. }
  32. private privateKey: IKeyPair | undefined;
  33. private wsUrlBase: string;
  34. private bootstrapPromise: any;
  35. private bootstrapResult: any;
  36. constructor(private urlBase: string, private ipfsUrlBase: string, private storage: Storage, private webClient: IWebClient, private crypto: ICrypto) {
  37. this.wsUrlBase = urlBase.replace(/^http/i, 'ws');
  38. }
  39. public getPub(): Promise<string> {
  40. return new Promise(async (resolve, reject) => {
  41. await this.bootstrap();
  42. try {
  43. if (!this.privateKey) {
  44. throw new Error('missing privateKey');
  45. }
  46. resolve(this.privateKey.getPublicHash());
  47. } catch (e) {
  48. reject(e);
  49. }
  50. });
  51. }
  52. public bootstrap() {
  53. if (this.bootstrapResult) {
  54. return Promise.resolve(this.bootstrapResult);
  55. }
  56. if (this.bootstrapPromise) {
  57. return this.bootstrapPromise;
  58. }
  59. return this.bootstrapPromise = new Promise((resolve, reject) => {
  60. this.storage.get('notaprivatekey').then(async (privateKeyFromStorage) => {
  61. if (privateKeyFromStorage == null) {
  62. console.log('no private key in storage. generating new');
  63. const privateKey = await this.crypto.generateRsaKeyPair(2048);
  64. const exportResult = await privateKey.export();
  65. this.storage.set('notaprivatekey', exportResult).then(err => {
  66. // whatever
  67. }).catch(reject);
  68. this.privateKey = privateKey;
  69. resolve(true);
  70. } else {
  71. // console.log('importing privatekey');
  72. this.privateKey = await this.crypto.importRsaKeyPair(privateKeyFromStorage);
  73. resolve(true);
  74. }
  75. }).catch(reject);
  76. });
  77. }
  78. public async getNonce() {
  79. const nonce = await this.webClient.request({
  80. method: 'GET',
  81. url: this.urlBase + '/bank/nonce'
  82. });
  83. return Number(nonce);
  84. }
  85. public async getBalance(): Promise<number> {
  86. const nonce = await this.getNonce();
  87. const retrieveRequest = await this.makePlaintextPayload(JSON.stringify({
  88. _date: new Date().toISOString(),
  89. _nonce: nonce
  90. }));
  91. const topicURL = this.urlBase + '/bank/getbalance';
  92. const postResponse:any = await this.webClient.requestJSON({
  93. body: retrieveRequest,
  94. method: 'POST',
  95. url: topicURL
  96. });
  97. return postResponse.balance;
  98. }
  99. public async upload(params: UploadItemParameters) {
  100. const url = this.urlBase + '/bank/upload';
  101. const formData: any = {};
  102. formData.creator = await this.getPub();
  103. if (params.fileData) {
  104. formData.file = {
  105. value: params.fileData,
  106. options: {
  107. filename: params.fileName
  108. }
  109. };
  110. }
  111. if (params.thumbFileData) {
  112. formData.thumb = {
  113. value: params.thumbFileData,
  114. options: {
  115. filename: params.thumbFileName
  116. }
  117. };
  118. }
  119. if (params.links) {
  120. formData.links = JSON.stringify(params.links);
  121. }
  122. for (const attr of ['title', 'text', 'type', 'dmMe']) {
  123. if (params[attr] != null) {
  124. formData[attr] = '' + params[attr];
  125. }
  126. }
  127. // console.log('formData', formData);
  128. const uploadResponse: any = await this.webClient.requestJSON({
  129. formData,
  130. method: 'POST',
  131. url
  132. });
  133. // console.log('uploadResponse', uploadResponse);
  134. return uploadResponse.hash;
  135. }
  136. public async uploadSlimJSON(item: any) {
  137. const url = this.urlBase + '/bank/upload/slim';
  138. const uploadResponse: any = await this.webClient.requestJSON({
  139. body: item,
  140. method: 'POST',
  141. url
  142. });
  143. // console.log('uploadResponse', uploadResponse);
  144. return uploadResponse.hash;
  145. }
  146. public async uploadSlimText(item: string) {
  147. const url = this.urlBase + '/bank/upload/slim';
  148. const uploadResponse: any = JSON.parse(await this.webClient.request({
  149. body: item,
  150. headers: {
  151. 'content-type': 'text/plain'
  152. },
  153. method: 'POST',
  154. url
  155. }));
  156. // console.log('uploadResponse', uploadResponse);
  157. return uploadResponse.hash;
  158. }
  159. public async appendPrivate(peerAddr: string, topic: string, hash?: string, replaceHash?: string, deleteHash?: string, onlyHash?: string) {
  160. const nonce = await this.getNonce();
  161. const payload = await this.makePlaintextPayload(JSON.stringify({
  162. _date: new Date().toISOString(),
  163. _nonce: nonce,
  164. deleteHash,
  165. hash,
  166. onlyHash,
  167. replaceHash,
  168. }));
  169. const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(peerAddr) + '/' + encodeURIComponent(topic);
  170. const result = await this.webClient.request({
  171. body: JSON.stringify(payload),
  172. headers: {
  173. 'content-type': 'application/json'
  174. },
  175. method: 'PUT',
  176. url: topicURL
  177. });
  178. console.log('appended to ', peerAddr, topic, hash, replaceHash, deleteHash, result);
  179. }
  180. public async retrievePrivate(peerAddr: string, topic: string) {
  181. const nonce = await this.getNonce();
  182. const retrieveRequest = await this.makePlaintextPayload(JSON.stringify({
  183. _date: new Date().toISOString(),
  184. _nonce: nonce
  185. }));
  186. const topicURL = this.urlBase + '/bank/private/' + encodeURIComponent(peerAddr) + '/' + encodeURIComponent(topic);
  187. const result = await this.webClient.request({
  188. body: JSON.stringify(retrieveRequest),
  189. headers: {
  190. 'content-type': 'application/json'
  191. },
  192. method: 'POST',
  193. url: topicURL
  194. });
  195. return result;
  196. }
  197. public async subscribePrivate(peerAddr: string, topic: string, connectCallback: () => void, messageCallback: (data: any) => void) {
  198. await this.connectWebsocket(peerAddr, topic, connectCallback, messageCallback);
  199. }
  200. public async getOrCreateContact(peerId: string, addressType: string, addressValue: string, contactBook?: ContactBook): Promise<ContactItem> {
  201. if (contactBook == null) {
  202. console.log('warning: inefficient');
  203. contactBook = await this.getContactBook(peerId);
  204. }
  205. const existing = contactBook.lookupByAddress(addressType, addressValue);
  206. if (existing != null) {
  207. return existing;
  208. }
  209. console.log('creating new contact', peerId, addressType, addressValue);
  210. return await this.createContact(peerId, addressType, addressValue);
  211. }
  212. public async createContact(peerId: string, addressType?: string, addressValue?: string): Promise<ContactItem> {
  213. const contactId = uuid();
  214. const newItem:any = {
  215. addrs: [
  216. ],
  217. id: contactId
  218. };
  219. if (addressType != null && addressValue != null) {
  220. newItem.addrs.push(new ContactAddress(addressType, addressValue).toPrefixedString());
  221. }
  222. const newItemHash = await this.uploadSlimJSON(newItem);
  223. await this.appendPrivate(peerId, '📇', newItemHash);
  224. const contactBook2 = await this.getContactBook(peerId);
  225. return (await contactBook2.lookupById(contactId)) as ContactItem;
  226. }
  227. public async getAllContacts(peerId: string): Promise<ContactItem[]> {
  228. const contactList = await this.retrievePrivate(peerId, '📇');
  229. const items = await this.getItemsForCommaList(contactList);
  230. return items.map(data => new ContactItem(data));
  231. }
  232. public async getContactBook(peerId: string): Promise<ContactBook> {
  233. if (peerId == null) {
  234. throw new Error('Missing peerId');
  235. }
  236. return new ContactBook(await this.getAllContacts(peerId));
  237. }
  238. public async updateContact(peerId: string, contactId: string, newProperties: any): Promise<ContactItem> {
  239. const contactBook = await this.getContactBook(peerId);
  240. const existing = await contactBook.lookupById(contactId);
  241. if (!existing) {
  242. throw new Error('missing contact with id ' + contactId);
  243. }
  244. const existingData = existing.getData();
  245. const newProps: any = mergeDeep({}, newProperties);
  246. delete newProps.id;
  247. const newItem: any = mergeDeep(existingData, newProps);
  248. delete newItem.hash;
  249. newItem.lastChanged = new Date().toISOString();
  250. const newItemHash = await this.uploadSlimJSON(newItem);
  251. await this.appendPrivate(peerId, '📇', newItemHash, existing.hash);
  252. const contactBook2 = await this.getContactBook(peerId);
  253. return (await contactBook2.lookupById(contactId)) as ContactItem;
  254. }
  255. public async getContentItemByHash(hashInPlaylist: string): Promise<ContentItem> {
  256. const hash = this.parseItemHash(hashInPlaylist).hash;
  257. const contentParams = (await this.webClient.requestJSON({
  258. method: 'get',
  259. url: this.ipfsUrlBase + '/ipfs/' + hash + '/content.json'
  260. })) as ContentParams;
  261. return new ContentItem(hashInPlaylist, hash, contentParams);
  262. }
  263. public async getItemsForCommaList(commaList: string): Promise<any[]> {
  264. const itemHashes = commaList.split(',').filter(x => x.trim() !== '');
  265. const items: any[] = await Promise.all(itemHashes.map(itemId => {
  266. const itemHash = this.parseItemHash(itemId).hash;
  267. return this.webClient.requestJSON({
  268. method: 'get',
  269. url: this.ipfsUrlBase + '/ipfs/' + itemHash,
  270. });
  271. }));
  272. for (const item of items) {
  273. item.hash = itemHashes.shift();
  274. }
  275. return items;
  276. }
  277. public parseItemHash(itemHash: string) {
  278. let type: string | null = null;
  279. let timestamp: string | null = null;
  280. let hash: string | null = null;
  281. if (itemHash.startsWith('/ipfs/')) {
  282. itemHash = itemHash.substring(6);
  283. }
  284. const matched = itemHash.match(/^([0-9]*)_(..)_(.*)$/);
  285. if (matched) {
  286. timestamp = matched[1];
  287. type = matched[2];
  288. hash = matched[3];
  289. }
  290. if (!type) {
  291. type = 'CO';
  292. }
  293. if (!hash) {
  294. hash = itemHash;
  295. }
  296. return { type, timestamp, hash };
  297. }
  298. public async runAgent(address: string, topic: string, storage: Storage, itemProcessCallback: (item: ContentItem | ContactItem) => Promise<any>) {
  299. await this.subscribePrivate(
  300. address,
  301. topic,
  302. () => {
  303. // console.log('websocket connected');
  304. },
  305. async () => {
  306. await agentUpdate();
  307. }
  308. );
  309. const agentUpdate = async () => {
  310. const agentConfig = (await storage.get('config')) || {};
  311. const items = await this.retrievePrivate(address, topic);
  312. const itemsList = items.split(',').filter((x) => x.trim() !== '');
  313. console.log('itemsList', itemsList);
  314. for (const itemId of itemsList) {
  315. const processed = agentConfig.processed || [];
  316. const failed = agentConfig.failed || [];
  317. if (processed.includes(itemId) || failed.includes(itemId)) {
  318. continue;
  319. }
  320. try {
  321. const item = await this.getContentItemByHash(itemId);
  322. console.log('gotItem', item);
  323. await itemProcessCallback(item);
  324. processed.push(itemId);
  325. agentConfig.processed = processed;
  326. await storage.set('config', agentConfig);
  327. } catch (e) {
  328. console.error('error processing item', itemId, e);
  329. failed.push(itemId);
  330. agentConfig.failed = failed;
  331. await storage.set('config', agentConfig);
  332. }
  333. }
  334. }
  335. await agentUpdate();
  336. }
  337. private connectWebsocket(peerAddr: string, topic: string, connectCallback: () => void, messageCallback: (data: any) => void) {
  338. return new Promise(async (resolve, reject) => {
  339. const nonce = await this.getNonce();
  340. const retrieveRequest: any = await this.makePlaintextPayload(JSON.stringify({
  341. _date: new Date().toISOString(),
  342. _nonce: nonce,
  343. addr: peerAddr,
  344. topic
  345. }));
  346. const jsonOutput = JSON.stringify(retrieveRequest);
  347. const base64ed = Buffer.from(jsonOutput).toString('base64');
  348. const encoded = encodeURIComponent(base64ed);
  349. const ws = new WebSocket(this.wsUrlBase + '/bank/ws?arg=' + encoded);
  350. ws.on('open', () => {
  351. connectCallback();
  352. });
  353. ws.on('message', data => {
  354. messageCallback(data);
  355. });
  356. const reconnect = () => {
  357. // console.log('reconnect');
  358. try {
  359. ws.terminate();
  360. } finally {
  361. console.log('reconnecting in 5s');
  362. setTimeout(async () => {
  363. try {
  364. await this.connectWebsocket(peerAddr, topic, connectCallback, messageCallback);
  365. } catch (e) {
  366. console.error('error reconnecting', e);
  367. }
  368. }, 5000);
  369. }
  370. };
  371. ws.on('error', err => {
  372. console.error('websocket error', err);
  373. });
  374. ws.on('close', err => {
  375. reconnect();
  376. });
  377. resolve();
  378. });
  379. }
  380. private async makePlaintextPayload(message: string) {
  381. const messageBytes = Buffer.from(message, 'utf-8');
  382. await this.bootstrap();
  383. if (!this.privateKey) {
  384. throw new Error('missing privateKey');
  385. }
  386. const signatureBytes = await this.privateKey.sign(messageBytes);
  387. const publicKey = await this.privateKey.getPublicKey();
  388. const pubHash = await this.privateKey.getPublicHash();
  389. const result = {
  390. date: new Date().toISOString(),
  391. msg: encodeHex(messageBytes),
  392. pub: encodeHex(Buffer.from(publicKey, 'hex')),
  393. pubHash,
  394. sig: encodeHex(Buffer.from(signatureBytes, 'hex')),
  395. };
  396. return result;
  397. }
  398. }