UNPKG

blockstack-storage

Version:

The Blockstack Javascript library for storage.

816 lines (663 loc) 27.3 kB
'use strict'; Object.defineProperty(exports, "__esModule", { value: true }); exports.datastoreGetId = datastoreGetId; exports.datastoreCreateRequest = datastoreCreateRequest; exports.datastoreCreate = datastoreCreate; exports.datastoreCreateIsPartialFailure = datastoreCreateIsPartialFailure; exports.datastoreCreateSetPartialFailure = datastoreCreateSetPartialFailure; exports.datastoreCreateSetRetry = datastoreCreateSetRetry; exports.datastoreCreateUnsetPartialFailure = datastoreCreateUnsetPartialFailure; exports.datastoreDeleteRequest = datastoreDeleteRequest; exports.datastoreDelete = datastoreDelete; exports.datastoreRequestPathInfo = datastoreRequestPathInfo; exports.datastoreMount = datastoreMount; exports.datastoreMountOrCreate = datastoreMountOrCreate; var _policy = require('./policy'); var _requests = require('./requests'); var _api = require('./api'); var _schemas = require('./schemas'); var _blob = require('./blob'); var _inode = require('./inode'); var _util = require('./util'); var _blockstack = require('blockstack'); var _metadata = require('./metadata'); var http = require('http'); var uuid4 = require('uuid/v4'); var bitcoinjs = require('bitcoinjs-lib'); var BigInteger = require('bigi'); var Promise = require('promise'); var assert = require('assert'); var Ajv = require('ajv'); var jsontokens = require('jsontokens'); var urlparse = require('url'); /* * Convert a datastore public key to its ID. * @param ds_public_key (String) hex-encoded ECDSA public key */ function datastoreGetId(ds_public_key_hex) { var ec = bitcoinjs.ECPair.fromPublicKeyBuffer(Buffer.from(ds_public_key_hex, 'hex')); return ec.getAddress(); } /* * Create the signed request to create a datastore. * This information can be fed into datastoreCreate() * Returns an object with: * .datastore_info: datastore information * .datastore_sigs: signatures over the above. */ function datastoreCreateRequest(ds_type, ds_private_key_hex, drivers, device_id, all_device_ids) { assert(ds_type === 'datastore' || ds_type === 'collection'); var root_uuid = uuid4(); var ds_public_key = (0, _blockstack.getPubkeyHex)(ds_private_key_hex); var datastore_id = datastoreGetId(ds_public_key); // make empty device root var device_root = (0, _inode.makeEmptyDeviceRootDirectory)(datastore_id, []); var device_root_data_id = datastore_id + '.' + root_uuid; var device_root_blob = (0, _blob.makeDataInfo)(device_root_data_id, (0, _util.jsonStableSerialize)(device_root), device_id); var device_root_str = (0, _util.jsonStableSerialize)(device_root_blob); // actual datastore payload var datastore_info = { 'type': ds_type, 'pubkey': ds_public_key, 'drivers': drivers, 'device_ids': all_device_ids, 'root_uuid': root_uuid }; var data_id = datastore_id + '.datastore'; var datastore_blob = (0, _blob.makeDataInfo)(data_id, (0, _util.jsonStableSerialize)(datastore_info), device_id); var datastore_str = (0, _util.jsonStableSerialize)(datastore_blob); // sign them all var root_sig = (0, _blob.signDataPayload)(device_root_str, ds_private_key_hex); var datastore_sig = (0, _blob.signDataPayload)(datastore_str, ds_private_key_hex); // make and sign tombstones for the root var root_data_id = datastore_id + '.' + root_uuid; var root_tombstones = (0, _blob.makeDataTombstones)(all_device_ids, root_data_id); var signed_tombstones = (0, _blob.signDataTombstones)(root_tombstones, ds_private_key_hex); var info = { 'datastore_info': { 'datastore_blob': datastore_str, 'root_blob': device_root_str }, 'datastore_sigs': { 'datastore_sig': datastore_sig, 'root_sig': root_sig }, 'root_tombstones': signed_tombstones }; return info; } /* * Create a datastore * Asynchronous; returns a Promise that resolves to either {'status': true} (on success) * or {'error': ...} (on error) */ function datastoreCreate(blockstack_hostport, blockstack_session_token, datastore_request) { var datastore_pubkey = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; var apiPassword = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : null; var payload = { 'datastore_info': { 'datastore_blob': datastore_request.datastore_info.datastore_blob, 'root_blob': datastore_request.datastore_info.root_blob }, 'datastore_sigs': { 'datastore_sig': datastore_request.datastore_sigs.datastore_sig, 'root_sig': datastore_request.datastore_sigs.root_sig }, 'root_tombstones': datastore_request.root_tombstones }; var hostinfo = (0, _util.splitHostPort)(blockstack_hostport); var options = { 'method': 'POST', 'host': hostinfo.host, 'port': hostinfo.port, 'path': '/v1/stores' }; if (apiPassword) { assert(datastore_pubkey, 'Need datastore public key for password-based datastore creation'); options['path'] += '?datastore_pubkey=' + datastore_pubkey; options['headers'] = { 'Authorization': 'bearer ' + apiPassword }; } else { options['headers'] = { 'Authorization': 'bearer ' + blockstack_session_token }; } var body = JSON.stringify(payload); options['headers']['Content-Type'] = 'application/json'; options['headers']['Content-Length'] = new Buffer(body).length; return (0, _requests.httpRequest)(options, _schemas.PUT_DATASTORE_RESPONSE, body); } /* * Did we partially succeed to create the datastore indicated by the session token? * Return true if so; false if not. */ function datastoreCreateIsPartialFailure(sessionToken) { var session_app_name = (0, _metadata.getSessionAppName)(sessionToken); var session = jsontokens.decodeToken(sessionToken).payload; var blockchain_id = (0, _metadata.getBlockchainIDFromSessionOrDefault)(session); var gaia_state = (0, _metadata.getGaiaLocalData)(); var marker = blockchain_id + '/' + session_app_name; if (!gaia_state.partial_create_failures) { return false; } if (gaia_state.partial_create_failures[marker]) { return true; } return false; } /* * Remember that we failed to create this datastore, and that * a subsequent datastoreCreate() should succeed. */ function datastoreCreateSetPartialFailure(sessionToken) { var session_app_name = (0, _metadata.getSessionAppName)(sessionToken); var session = jsontokens.decodeToken(sessionToken).payload; var blockchain_id = (0, _metadata.getBlockchainIDFromSessionOrDefault)(session); var gaia_state = (0, _metadata.getGaiaLocalData)(); var marker = blockchain_id + '/' + session_app_name; if (!gaia_state.partial_create_failures) { gaia_state.partial_create_failures = {}; } gaia_state.partial_create_failures[marker] = true; (0, _metadata.setGaiaLocalData)(gaia_state); } /* * This is the "public" version of datastoreCreateSetPartialFailure * that clients should call */ function datastoreCreateSetRetry(sessionToken) { return datastoreCreateSetPartialFailure(sessionToken); } /* * Remember that we succeeded to create this datastore, and that * a subsequent datastoreCreate() should fail. */ function datastoreCreateUnsetPartialFailure(sessionToken) { var session_app_name = (0, _metadata.getSessionAppName)(sessionToken); var session = jsontokens.decodeToken(sessionToken).payload; var blockchain_id = (0, _metadata.getBlockchainIDFromSessionOrDefault)(session); var gaia_state = (0, _metadata.getGaiaLocalData)(); var marker = blockchain_id + '/' + session_app_name; if (!gaia_state.partial_create_failures) { gaia_state.partial_create_failures = {}; } gaia_state.partial_create_failures[marker] = false; (0, _metadata.setGaiaLocalData)(gaia_state); } /* * Generate the data needed to delete a datastore. * * @param ds (Object) a datastore context (will be loaded from localstorage if not given) * * Returns an object to be given to datastoreDelete() */ function datastoreDeleteRequest() { var ds = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; if (!ds) { var app_name = (0, _metadata.getSessionAppName)(); assert(app_name); var _datastore_id = (0, _metadata.getSessionDatastoreID)(); assert(_datastore_id); ds = (0, _metadata.getCachedMountContext)(_datastore_id, app_name); assert(ds); } var datastore_id = ds.datastore_id; var device_ids = ds.datastore.device_ids; var root_uuid = ds.datastore.root_uuid; var data_id = datastore_id + '.datastore'; var root_data_id = datastore_id + '.' + root_uuid; var tombstones = (0, _blob.makeDataTombstones)(device_ids, data_id); var signed_tombstones = (0, _blob.signDataTombstones)(tombstones, ds.privkey_hex); var root_tombstones = (0, _blob.makeDataTombstones)(device_ids, root_data_id); var signed_root_tombstones = (0, _blob.signDataTombstones)(root_tombstones, ds.privkey_hex); var ret = { 'datastore_tombstones': signed_tombstones, 'root_tombstones': signed_root_tombstones }; return ret; } /* * Delete a datastore * * @param ds (Object) OPTINOAL: the datastore context (will be loaded from localStorage if not given) * @param ds_tombstones (Object) OPTINOAL: signed information from datastoreDeleteRequest() * @param root_tombstones (Object) OPTINAL: signed information from datastoreDeleteRequest() * * Asynchronous; returns a Promise that resolves to either {'status': true} on success * or {'error': ...} on error */ function datastoreDelete() { var ds = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : null; var ds_tombstones = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; var root_tombstones = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null; if (!ds) { var session = jsontokens.decodeToken((0, _metadata.getSessionToken)()).payload; var app_name = (0, _metadata.getSessionAppName)(); assert(app_name); var datastore_id = (0, _metadata.getSessionDatastoreID)(); assert(datastore_id); ds = (0, _metadata.getCachedMountContext)(datastore_id, app_name); assert(ds); } if (!ds_tombstones || !root_tombstones) { var delete_info = datastoreDeleteRequest(ds); ds_tombstones = delete_info['datastore_tombstones']; root_tombstones = delete_info['root_tombstones']; } var payload = { 'datastore_tombstones': ds_tombstones, 'root_tombstones': root_tombstones }; var options = { 'method': 'DELETE', 'host': ds.host, 'port': ds.port, 'path': '/v1/stores' }; options['headers'] = { 'Authorization': 'bearer ' + (0, _metadata.getSessionToken)() }; var body = JSON.stringify(payload); options['headers']['Content-Type'] = 'application/json'; options['headers']['Content-Length'] = new Buffer(body).length; return (0, _requests.httpRequest)(options, _schemas.SUCCESS_FAIL_SCHEMA, body); } /* * Are we in single-reader storage? * i.e. does this device's session token own this datastore? */ function isSingleReaderMount(sessionToken, datastore_id) { var blockchain_id = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null; if (!blockchain_id) { blockchain_id = (0, _metadata.getSessionBlockchainID)(sessionToken); } if (!blockchain_id) { // no blockchain ID given means we can't be in multi-reader storage mode return true; } if (datastore_id === jsontokens.decodeToken(sessionToken).payload.app_user_id) { // the session token indicates that the datastore we're mounting was, in fact, // created by this device. We can use datastore IDs and device IDs. return true; } return false; } /* * Make a request's query string for either single-reader * or multi-reader storage, given the datastore mount context. * * Returns {'store_id': ..., 'qs': ..., 'host': ..., 'port': ...} on success * Throws on error. */ function datastoreRequestPathInfo(dsctx) { assert(dsctx.blockchain_id && dsctx.app_name || dsctx.datastore_id); if (dsctx.datastore_id) { // single-reader mode var device_ids = []; var public_keys = []; var _iteratorNormalCompletion = true; var _didIteratorError = false; var _iteratorError = undefined; try { for (var _iterator = dsctx['app_public_keys'][Symbol.iterator](), _step; !(_iteratorNormalCompletion = (_step = _iterator.next()).done); _iteratorNormalCompletion = true) { var apk = _step.value; device_ids.push(apk['device_id']); public_keys.push(apk['public_key']); } } catch (err) { _didIteratorError = true; _iteratorError = err; } finally { try { if (!_iteratorNormalCompletion && _iterator.return) { _iterator.return(); } } finally { if (_didIteratorError) { throw _iteratorError; } } } var device_ids_str = device_ids.join(','); var public_keys_str = public_keys.join(','); var info = { 'store_id': dsctx.datastore_id, 'qs': 'device_ids=' + device_ids_str + '&device_pubkeys=' + public_keys_str, 'host': dsctx.host, 'port': dsctx.port }; return info; } else { // multi-reader mode var _info = { 'store_id': dsctx.app_name, 'qs': 'blockchain_id=' + dsctx.blockchain_id, 'host': dsctx.host, 'port': dsctx.port }; return _info; } } /* * Look up a datastore and establish enough contextual information to do subsequent storage operations. * Asynchronous; returns a Promise * * opts is an object that must contain EITHER: * * a single-reader datastore identifier, which is: * * * datastoreID (string) the datastore ID * * * deviceID (string) this device ID * * * dataPubkeys (array) this is an array of {'device_id': ..., 'public_key': ...} objects, where one such object has `device_id` equal to opts.device_id * OR * * a multi-reader datastore identifier, which is: * * * appName (string) the application name * * * blockchainID (string) the blockchain ID that owns the datastore * * If we're going to write to this datastore, then we *additionally* need: * * appPrivateKey (string) the application private key * * sessionToken (string) the session JWT (optional) * * sessionToken may be given as an opt, in which case the following fields will be used * if not provided in opts: * * appName from session.app_domain * * blockchainID from session.blockchain_id * * dataPubkeys from session.app_public_keys * * deviceID from session.device_id * * Uses opts.apiPassword for authentication if given. * * Returns a Promise that resolves to a datastore connection, * with the following properties: * .host: blockstack host * .datastore: datastore object * * Returns a Promise that resolves to null, if the datastore does not exist. * * Throws an error on all other errors */ function datastoreMount(opts) { var no_cache = opts.noCachedMounts; var sessionToken = opts.sessionToken; var blockchain_id = opts.blockchainID; var app_name = opts.appName; var this_device_id = opts.deviceID; var app_public_keys = opts.dataPubkeys; if (!sessionToken) { sessionToken = (0, _metadata.getSessionToken)(); assert(sessionToken); } var session_blockchain_id = (0, _metadata.getSessionBlockchainID)(sessionToken); var session_datastore_id = (0, _metadata.getSessionDatastoreID)(sessionToken); var api_endpoint = null; if (session_blockchain_id === blockchain_id) { // this is definitely single-reader blockchain_id = null; } // get our app private key var userData = (0, _metadata.getUserData)(); var datastore_privkey_hex = userData.appPrivateKey; if (!datastore_privkey_hex) { // can only happen if testing datastore_privkey_hex = opts.appPrivateKey; } assert(datastore_privkey_hex); var session = jsontokens.decodeToken(sessionToken).payload; var session_app_name = (0, _metadata.getSessionAppName)(sessionToken); // did we try to create this before, but failed part-way through? if (datastoreCreateIsPartialFailure(sessionToken)) { // technically does not exist yet. console.log('Will not mount datastore due to previous partial failure'); return new Promise(function (resolve, reject) { resolve(null); }); } // maybe cached? if (!no_cache) { // if this is our datastore, use our datastore ID. // otherwise use the blockchain ID var ds_cache_key = blockchain_id || session_datastore_id; var ds = (0, _metadata.getCachedMountContext)(ds_cache_key, session_app_name); if (ds) { return new Promise(function (resolve, reject) { resolve(ds); }); } } // not cached. setup from session token if (!this_device_id) { this_device_id = session.device_id; assert(this_device_id); } if (!app_public_keys) { app_public_keys = session.app_public_keys; } api_endpoint = session.api_endpoint; if (!blockchain_id) { assert(session_datastore_id, 'No datastore ID in session'); console.log('Single-reader/writer mount of ' + session_datastore_id); } else { // multi-reader info assert(app_name || session, 'Need either appName or sessionToken in opts'); if (!app_name && session) { app_name = (0, _metadata.getSessionAppName)(sessionToken); assert(app_name, 'Invalid session token ' + sessionToken); } console.log('Multi-reader mount of ' + blockchain_id + '/' + app_name); } assert(blockchain_id && app_name || session_datastore_id, 'Need either blockchain_id (' + blockchain_id + ') / app_name (' + app_name + ') or datastore_id (' + session_datastore_id + ')'); if (api_endpoint.indexOf('://') < 0) { var new_api_endpoint = 'https://' + api_endpoint; if (urlparse.parse(new_api_endpoint).hostname === 'localhost') { new_api_endpoint = 'http://' + api_endpoint; } api_endpoint = new_api_endpoint; } var urlinfo = urlparse.parse(api_endpoint); var blockstack_hostport = urlinfo.host; var scheme = urlinfo.protocol.replace(':', ''); var host = urlinfo.hostname; var port = urlinfo.port; var ctx = { 'scheme': scheme, 'host': host, 'port': port, 'blockchain_id': blockchain_id, 'app_name': app_name, 'datastore_id': session_datastore_id, 'app_public_keys': app_public_keys, 'device_id': this_device_id, 'datastore': null, 'privkey_hex': null, 'created': false }; if (!blockchain_id) { // this is *our* datastore ctx['privkey_hex'] = datastore_privkey_hex; } var path_info = datastoreRequestPathInfo(ctx); assert(path_info.store_id, 'BUG: no store ID deduced from ' + JSON.stringify(ctx)); var options = { 'method': 'GET', 'host': path_info.host, 'port': path_info.port, 'path': '/v1/stores/' + path_info.store_id + '?' + path_info.qs }; console.log('Mount datastore ' + options.path); if (opts.apiPassword && datastore_privkey_hex) { options['headers'] = { 'Authorization': 'bearer ' + opts.apiPassword }; // need to explicitly pass the datastore public key options['path'] += '&datastore_pubkey=' + (0, _blockstack.getPubkeyHex)(datastore_privkey_hex); } else { options['headers'] = { 'Authorization': 'bearer ' + sessionToken }; } return (0, _requests.httpRequest)(options, _schemas.DATASTORE_RESPONSE_SCHEMA).then(function (ds) { if (!ds || ds.error) { // ENOENT? if (!ds || ds.errno === 'ENOENT') { return null; } else { var errorMsg = ds.error || 'No response given'; throw new Error('Failed to get datastore: ' + errorMsg); } } else { ctx['datastore'] = ds.datastore; // save if (!no_cache) { // if this is our datastore, use the datastore ID. // otherwise use the blockchain ID var _ds_cache_key = blockchain_id || session_datastore_id; if (_ds_cache_key === session_datastore_id) { // this is *our* datastore. We had better have the data key assert(datastore_privkey_hex, 'Missing data private key'); assert(ctx.privkey_hex, 'Missing data private key in mount context'); } console.log('Cache datastore for ' + _ds_cache_key + '/' + session_app_name); (0, _metadata.setCachedMountContext)(_ds_cache_key, session_app_name, ctx); } return ctx; } }); } /* * Connect to or create a datastore. * Asynchronous, returns a Promise * * Returns a Promise that yields a datastore connection context. * If we created this datastore, then .urls = {'datastore': [...], 'root': [...]} will be defined in the returned context. * * Throws on error. * */ function datastoreMountOrCreate() { var replication_strategy = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var sessionToken = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; var appPrivateKey = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : null; var apiPassword = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : null; if (!sessionToken) { var userData = (0, _metadata.getUserData)(); sessionToken = userData.coreSessionToken; assert(sessionToken); } // decode var session = jsontokens.decodeToken(sessionToken).payload; var session_blockchain_id = (0, _metadata.getBlockchainIDFromSessionOrDefault)(session); var session_datastore_id = (0, _metadata.getSessionDatastoreID)(sessionToken); var session_app_name = (0, _metadata.getSessionAppName)(sessionToken); // cached, and not partially-failed create? var ds = (0, _metadata.getCachedMountContext)(session_datastore_id, session_app_name); if (ds && !datastoreCreateIsPartialFailure(sessionToken)) { return new Promise(function (resolve, reject) { resolve(ds); }); } // no cached datastore context. // go ahead and create one (need appPrivateKey) if (!appPrivateKey) { var _userData = (0, _metadata.getUserData)(); appPrivateKey = _userData.appPrivateKey; assert(appPrivateKey); } var drivers = null; var app_name = (0, _metadata.getSessionAppName)(sessionToken); // find satisfactory storage drivers if (replication_strategy.drivers) { drivers = replication_strategy.drivers; } else { if (Object.keys(session.storage.preferences).includes(app_name)) { // app-specific preference drivers = session.storage.preferences[app_name]; } else { // select defaults given the replication strategy drivers = (0, _policy.selectDrivers)(replication_strategy, session.storage.classes); } } var api_endpoint = session.api_endpoint; if (api_endpoint.indexOf('://') < 0) { var new_api_endpoint = 'https://' + api_endpoint; if (urlparse.parse(new_api_endpoint).hostname === 'localhost') { new_api_endpoint = 'http://' + api_endpoint; } api_endpoint = new_api_endpoint; } var hostport = urlparse.parse(api_endpoint).host; var appPublicKeys = session.app_public_keys; var deviceID = session.device_id; var allDeviceIDs = []; var _iteratorNormalCompletion2 = true; var _didIteratorError2 = false; var _iteratorError2 = undefined; try { for (var _iterator2 = appPublicKeys[Symbol.iterator](), _step2; !(_iteratorNormalCompletion2 = (_step2 = _iterator2.next()).done); _iteratorNormalCompletion2 = true) { var apk = _step2.value; allDeviceIDs.push(apk['device_id']); } } catch (err) { _didIteratorError2 = true; _iteratorError2 = err; } finally { try { if (!_iteratorNormalCompletion2 && _iterator2.return) { _iterator2.return(); } } finally { if (_didIteratorError2) { throw _iteratorError2; } } } if (drivers) { console.log('Will use drivers ' + drivers.join(',')); } console.log('Datastore will span devices ' + allDeviceIDs.join(',')); var datastoreOpts = { 'appPrivateKey': appPrivateKey, 'sessionToken': sessionToken, 'apiPassword': apiPassword }; return datastoreMount(datastoreOpts).then(function (datastore_ctx) { if (!datastore_ctx) { // does not exist console.log("Datastore does not exist; creating..."); var info = datastoreCreateRequest('datastore', appPrivateKey, drivers, deviceID, allDeviceIDs); // go create it return datastoreCreate(hostport, sessionToken, info, (0, _blockstack.getPubkeyHex)(appPrivateKey), apiPassword).then(function (res) { if (res.error) { console.log(res.error); var errorNo = res.errno || 'UNKNOWN'; var errorMsg = res.error || 'UNKNOWN'; throw new Error('Failed to create datastore (errno ' + errorNo + '): ' + errorMsg); } assert(res.root_urls, 'Missing root URLs'); assert(res.datastore_urls, 'Missing datastore URLs'); // this create succeeded datastoreCreateUnsetPartialFailure(sessionToken); // this is required for testing purposes, since the core session token will not have been set var userData = (0, _metadata.getUserData)(); if (!userData.coreSessionToken && sessionToken || !userData.appPrivateKey && appPrivateKey) { console.log("\nIn test framework; saving session token\n"); if (!userData.coreSessionToken && sessionToken) { userData.coreSessionToken = sessionToken; } if (!userData.appPrivateKey && appPrivateKey) { userData.appPrivateKey = appPrivateKey; } (0, _metadata.setUserData)(userData); } // connect to it now return datastoreMount(datastoreOpts).then(function (datastore_ctx) { // return URLs as well datastore_ctx.urls = { root: res.root_urls, datastore: res.datastore_urls }; datastore_ctx.created = true; return datastore_ctx; }); }); } else if (datastore_ctx.error) { // some other error var errorMsg = datastore_ctx.error || 'UNKNOWN'; var errorNo = datastore_ctx.errno || 'UNKNOWN'; throw new Error('Failed to access datastore (errno ' + errorNo + '): ' + errorMsg); } else { // exists return datastore_ctx; } }); }