// @flow import analytics from 'analytics'; import { FETCH_TIMEOUT } from 'constants/errors'; import { NO_AUTH, X_LBRY_AUTH_TOKEN } from 'constants/token'; import fetchWithTimeout from 'util/fetch'; require('proxy-polyfill'); const CHECK_DAEMON_STARTED_TRY_NUMBER = 200; // Basic LBRY sdk connection config // Offers a proxy to call LBRY sdk methods const Lbry = { isConnected: false, connectPromise: null, daemonConnectionString: 'http://localhost:5279', alternateConnectionString: '', methodsUsingAlternateConnectionString: [], apiRequestHeaders: { 'Content-Type': 'application/json-rpc' }, // Allow overriding daemon connection string (e.g. to `/api/proxy` for lbryweb) setDaemonConnectionString: (value: string) => { Lbry.daemonConnectionString = value; }, setApiHeader: (key: string, value: string) => { Lbry.apiRequestHeaders = Object.assign(Lbry.apiRequestHeaders, { [key]: value }); }, unsetApiHeader: (key) => { Object.keys(Lbry.apiRequestHeaders).includes(key) && delete Lbry.apiRequestHeaders[key]; }, // Allow overriding Lbry methods overrides: {}, setOverride: (methodName, newMethod) => { Lbry.overrides[methodName] = newMethod; }, getApiRequestHeaders: () => Lbry.apiRequestHeaders, // Returns a human readable media type based on the content type or extension of a file that is returned by the sdk getMediaType: (contentType: ?string, fileName: ?string) => { if (fileName) { const formats = [ [/\.(mp4|m4v|webm|flv|f4v|ogv)$/i, 'video'], [/\.(mp3|m4a|aac|wav|flac|ogg|opus)$/i, 'audio'], [/\.(jpeg|jpg|png|gif|svg|webp)$/i, 'image'], [/\.(h|go|ja|java|js|jsx|c|cpp|cs|css|rb|scss|sh|php|py)$/i, 'script'], [/\.(html|json|csv|txt|log|md|markdown|docx|pdf|xml|yml|yaml)$/i, 'document'], [/\.(pdf|odf|doc|docx|epub|org|rtf)$/i, 'e-book'], [/\.(stl|obj|fbx|gcode)$/i, '3D-file'], [/\.(cbr|cbt|cbz)$/i, 'comic-book'], [/\.(lbry)$/i, 'application'], ]; const res = formats.reduce((ret, testpair) => { switch (testpair[0].test(ret)) { case true: return testpair[1]; default: return ret; } }, fileName); return res === fileName ? 'unknown' : res; } else if (contentType) { // $FlowFixMe return /^[^/]+/.exec(contentType)[0]; } return 'unknown'; }, // // Lbry SDK Methods // https://lbry.tech/api/sdk // status: (params = {}) => daemonCallWithResult('status', params), stop: () => daemonCallWithResult('stop', {}), version: () => daemonCallWithResult('version', {}), // Claim fetching and manipulation resolve: (params) => daemonCallWithResult('resolve', params, searchRequiresAuth), get: (params) => daemonCallWithResult('get', params), claim_search: (params) => daemonCallWithResult('claim_search', params, searchRequiresAuth), claim_list: (params) => daemonCallWithResult('claim_list', params), channel_create: (params) => daemonCallWithResult('channel_create', params), channel_update: (params) => daemonCallWithResult('channel_update', params), channel_import: (params) => daemonCallWithResult('channel_import', params), channel_list: (params) => daemonCallWithResult('channel_list', params), stream_abandon: (params) => daemonCallWithResult('stream_abandon', params), stream_list: (params) => daemonCallWithResult('stream_list', params), channel_abandon: (params) => daemonCallWithResult('channel_abandon', params), channel_sign: (params) => daemonCallWithResult('channel_sign', params), support_create: (params) => daemonCallWithResult('support_create', params), support_list: (params) => daemonCallWithResult('support_list', params), stream_repost: (params) => daemonCallWithResult('stream_repost', params), collection_resolve: (params) => daemonCallWithResult('collection_resolve', params), collection_list: (params) => daemonCallWithResult('collection_list', params), collection_create: (params) => daemonCallWithResult('collection_create', params), collection_update: (params) => daemonCallWithResult('collection_update', params), // File fetching and manipulation file_list: (params = {}) => daemonCallWithResult('file_list', params), file_delete: (params = {}) => daemonCallWithResult('file_delete', params), file_set_status: (params = {}) => daemonCallWithResult('file_set_status', params), blob_delete: (params = {}) => daemonCallWithResult('blob_delete', params), blob_list: (params = {}) => daemonCallWithResult('blob_list', params), file_reflect: (params = {}) => daemonCallWithResult('file_reflect', params), // Wallet utilities wallet_balance: (params = {}) => daemonCallWithResult('wallet_balance', params), wallet_decrypt: () => daemonCallWithResult('wallet_decrypt', {}), wallet_encrypt: (params = {}) => daemonCallWithResult('wallet_encrypt', params), wallet_unlock: (params = {}) => daemonCallWithResult('wallet_unlock', params), wallet_list: (params = {}) => daemonCallWithResult('wallet_list', params), wallet_send: (params = {}) => daemonCallWithResult('wallet_send', params), wallet_status: (params = {}) => daemonCallWithResult('wallet_status', params), address_is_mine: (params = {}) => daemonCallWithResult('address_is_mine', params), address_unused: (params = {}) => daemonCallWithResult('address_unused', params), address_list: (params = {}) => daemonCallWithResult('address_list', params), transaction_list: (params = {}) => daemonCallWithResult('transaction_list', params), utxo_release: (params = {}) => daemonCallWithResult('utxo_release', params), support_abandon: (params = {}) => daemonCallWithResult('support_abandon', params), purchase_list: (params = {}) => daemonCallWithResult('purchase_list', params), txo_list: (params = {}) => daemonCallWithResult('txo_list', params), account_list: (params = {}) => daemonCallWithResult('account_list', params), account_set: (params = {}) => daemonCallWithResult('account_set', params), sync_hash: (params = {}) => daemonCallWithResult('sync_hash', params), sync_apply: (params = {}) => daemonCallWithResult('sync_apply', params), // Preferences preference_get: (params = {}) => daemonCallWithResult('preference_get', params), preference_set: (params = {}) => daemonCallWithResult('preference_set', params), // Comments comment_list: (params = {}) => daemonCallWithResult('comment_list', params), comment_create: (params = {}) => daemonCallWithResult('comment_create', params), comment_hide: (params = {}) => daemonCallWithResult('comment_hide', params), comment_abandon: (params = {}) => daemonCallWithResult('comment_abandon', params), comment_update: (params = {}) => daemonCallWithResult('comment_update', params), // Connect to the sdk connect: () => { if (Lbry.connectPromise === null) { // $FlowFixMe Lbry.connectPromise = new Promise((resolve, reject) => { let tryNum = 0; // Check every half second to see if the daemon is accepting connections function checkDaemonStarted() { tryNum += 1; Lbry.status() .then(resolve) .catch(() => { if (tryNum <= CHECK_DAEMON_STARTED_TRY_NUMBER) { setTimeout(checkDaemonStarted, tryNum < 50 ? 400 : 1000); } else { reject(new Error('Unable to connect to LBRY')); } }); } checkDaemonStarted(); }); } // Flow thinks this could be empty, but it will always reuturn a promise // $FlowFixMe return Lbry.connectPromise; }, publish: (params = {}) => new Promise((resolve, reject) => { if (Lbry.overrides.publish) { Lbry.overrides.publish(params).then(resolve, reject); } else { apiCall('publish', params, resolve, reject); } }), }; const ApiFailureMgr = { MAX_FAILED_ATTEMPTS: 5, MAX_FAILED_GAP_MS: 500, BLOCKED_DURATION_MS: 60000, METHODS_TO_LOG: ['claim_search'], // Can check all, but narrow do claim_search only for now. failureTimestamps: {}, // { [key: string]: Array } logFailure: function (method: string, params: ?{}, timestamp: number) { if (this.isListedMethod(method)) { const key = this.getKey(method, params); const ts = this.failureTimestamps[key] || []; ts.push(timestamp); this.failureTimestamps[key] = ts; } }, logSuccess: function (method: string, params: ?{}) { if (this.isListedMethod(method)) { const key = this.getKey(method, params); delete this.failureTimestamps[key]; } }, isFailingAndShouldDrop: function (method: string, params: ?{}) { if (this.isListedMethod(method)) { const key = this.getKey(method, params); const fts = this.failureTimestamps[key]; if (fts && fts.length > this.MAX_FAILED_ATTEMPTS) { const ts2 = fts[fts.length - 1]; const ts1 = fts[fts.length - this.MAX_FAILED_ATTEMPTS]; const successivelyFailed = ts2 - ts1 < this.MAX_FAILED_GAP_MS; return successivelyFailed && Date.now() - ts2 < this.BLOCKED_DURATION_MS; } } return false; }, getKey: function (method: string, params: ?{}) { return method + '/' + JSON.stringify(params || {}); }, isListedMethod: function (method: string) { return this.METHODS_TO_LOG.includes(method); }, }; /** * Returns a customized error message for known scenarios. */ function resolveFetchErrorMsg(method: string, response: Response | string) { if (typeof response === 'object') { // prettier-ignore switch (response.status) { case 504: // Gateway timeout case 524: // Cloudflare: a timeout occurred switch (method) { case 'publish': return __('[Publish]: Your action timed out, but may have been completed. Refresh and check your Uploads or Wallet page to confirm after a few minutes.'); default: return `${method}: ${response.statusText} (${response.status})`; } default: return `${method}: ${response.statusText} (${response.status})`; } } else if (response === FETCH_TIMEOUT) { return `${method}: Your action timed out, but may have been completed.`; } else { return `${method}: fetch failed.`; } } function checkAndParse(response: Response, method: string) { if (!response.ok) { const errMsg = resolveFetchErrorMsg(method, response); throw Error(errMsg); } if (response.status >= 200 && response.status < 300) { return response.json(); } return response .json() .then((json) => { if (json.error) { const errorMessage = typeof json.error === 'object' ? json.error.message : json.error; return Promise.reject(new Error(errorMessage)); } else { return Promise.reject(new Error('Protocol error with unknown response signature')); } }) .catch(() => { // If not parsable, throw the initial response rather than letting // the json failure ("unexpected token at..") pass through. return Promise.reject(new Error(`${method}: ${response.statusText} (${response.status}, JSON)`)); }); } export function apiCall(method: string, params: ?{}, resolve: Function, reject: Function) { let apiRequestHeaders = Lbry.apiRequestHeaders; if (params && params[NO_AUTH]) { apiRequestHeaders = Object.assign({}, Lbry.apiRequestHeaders); delete apiRequestHeaders[X_LBRY_AUTH_TOKEN]; delete params[NO_AUTH]; } const counter = new Date().getTime(); const options = { method: 'POST', headers: apiRequestHeaders, body: JSON.stringify({ jsonrpc: '2.0', method, params, id: counter, }), }; if (ApiFailureMgr.isFailingAndShouldDrop(method, params)) { return Promise.reject('Dropped due to successive failures.'); } const connectionString = Lbry.methodsUsingAlternateConnectionString.includes(method) ? Lbry.alternateConnectionString : Lbry.daemonConnectionString; const SDK_FETCH_TIMEOUT_MS = 60000; return fetchWithTimeout(SDK_FETCH_TIMEOUT_MS, fetch(connectionString + '?m=' + method, options)) .then((response) => checkAndParse(response, method)) .then((response) => { const error = response.error || (response.result && response.result.error); if (error) { ApiFailureMgr.logFailure(method, params, counter); return reject(error); } else { ApiFailureMgr.logSuccess(method); return resolve(response.result); } }) .catch((err) => { ApiFailureMgr.logFailure(method, params, counter); if (err?.message === FETCH_TIMEOUT) { analytics.error(`${method}: timed out after ${SDK_FETCH_TIMEOUT_MS / 1000}s`); reject(resolveFetchErrorMsg(method, FETCH_TIMEOUT)); } else { reject(err); } }); } function daemonCallWithResult( name: string, params: ?{} = {}, checkAuthNeededFn: ?(?{}) => boolean = undefined ): Promise { return new Promise((resolve, reject) => { const skipAuth = checkAuthNeededFn ? !checkAuthNeededFn(params) : false; apiCall( name, skipAuth ? { ...params, [NO_AUTH]: true } : params, (result) => { resolve(result); }, reject ); }); } // This is only for a fallback // If there is a Lbry method that is being called by an app, it should be added to /flow-typed/Lbry.js const lbryProxy = new Proxy(Lbry, { get(target: LbryTypes, name: string) { if (name in target) { return target[name]; } return (params = {}) => new Promise((resolve, reject) => { apiCall(name, params, resolve, reject); }); }, }); /** * daemonCallWithResult hook that checks if the search option requires the * auth-token. This hook works for 'resolve' and 'claim_search'. * * @param options * @returns {boolean} */ function searchRequiresAuth(options: any) { const KEYS_REQUIRE_AUTH = ['include_purchase_receipt', 'include_is_my_output']; return options && KEYS_REQUIRE_AUTH.some((k) => options.hasOwnProperty(k)); } export default lbryProxy;