diff --git a/config/env/common.js b/config/env/common.js index 1f79500..dc31aa6 100644 --- a/config/env/common.js +++ b/config/env/common.js @@ -159,6 +159,11 @@ export default { // Preferred P2WDB provider preferredP2wdbProvider: process.env.PREFERRED_P2WDB_PROVIDER ? process.env.PREFERRED_P2WDB_PROVIDER + : '', + + // Preferred P2WDB provider + preferredIpfsFileProvider: process.env.PREFERRED_IPFS_FILE_PROVIDER + ? process.env.PREFERRED_IPFS_FILE_PROVIDER : '' } diff --git a/src/adapters/index.js b/src/adapters/index.js index 3abad47..38bd72f 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -15,6 +15,7 @@ import Passport from './passport.js' import Nodemailer from './nodemailer.js' import BCH from './bch/index.js' import P2WDB from './p2wdb/index.js' +import IpfsFiles from './ipfs-files/index.js' // const { wlogger } = require('./wlogger') import JSONFiles from './json-files.js' @@ -45,6 +46,7 @@ class Adapters { this.bch = new BCH(localConfig) this.p2wdb = new P2WDB(localConfig) this.wallet = new Wallet(localConfig) + this.ipfsFiles = new IpfsFiles(localConfig) // Get a valid JWT API key and instance bch-js. this.fullStackJwt = new FullStackJWT(config) diff --git a/src/adapters/ipfs-files/index.js b/src/adapters/ipfs-files/index.js new file mode 100644 index 0000000..543b03b --- /dev/null +++ b/src/adapters/ipfs-files/index.js @@ -0,0 +1,325 @@ +/* + This library contains code for interfacing to the ipfs-file-pin-service using + JSON RPC over IPFS. Most of these functions are called by the /files REST API + endpoints and the IPFS Use Cases library. +*/ + +// Public npm libraries +import { v4 as uid } from 'uuid' +import jsonrpc from 'jsonrpc-lite' + +// Local libraries +import wlogger from '../wlogger.js' + +// let _this + +class IpfsFilesAdapter { + constructor (localConfig = {}) { + // console.log('BCH localConfig: ', localConfig) + this.ipfs = localConfig.ipfs + if (!this.ipfs) { + throw new Error( + 'An instance of IPFS must be passed when instantiating the IPFS Files Adapter library.' + ) + } + // this.eventEmitter = localConfig.eventEmitter + // if (!this.eventEmitter) { + // throw new Error( + // 'An instance of an EventEmitter must be passed when instantiating the adapters.' + // ) + // } + + // Connect the RPC handler when the event fires with new data. + // this.eventEmitter.on('rpcData', this.rpcHandler) + + // Encapsulate dependencies + this.uid = uid + this.jsonrpc = jsonrpc + + // A queue for holding RPC data that has arrived. + this.rpcDataQueue = [] + + // _this = this // Global handle on instance of this Class. + + // Bind 'this' object to all subfunctions for this Class + this.rpcHandler = this.rpcHandler.bind(this) + this.getStatus = this.getStatus.bind(this) + this.selectProvider = this.selectProvider.bind(this) + } + + // This handler is triggered when RPC data comes in over IPFS. + // Handle RPC input, and add the response to the RPC queue. + // Once in the queue, it will get processed by waitForRPCResponse() + rpcHandler (data) { + try { + // Convert string input into an object. + // const jsonData = JSON.parse(data) + + // console.log(`JSON RPC response for ID ${data.payload.id} received.`) + + this.rpcDataQueue.push(data) + } catch (err) { + console.error('Error in files/rpcHandler(): ', err) + // Do not throw error. This is a top-level function. + } + } + + // Get the status of BCH wallet services this node can talk to. Returns an + // array of BCH wallet service providers. + async getStatus () { + try { + const peerData = this.ipfs.ipfsCoordAdapter.ipfsCoord.thisNode.peerData + // console.log(`peerData: ${JSON.stringify(peerData, null, 2)}`) + + // const status = { + // state: this.ipfs.ipfsCoordAdapter.state + // } + + // Add names to the IPFS IDs for each provider. + const initialServiceProviders = + this.ipfs.ipfsCoordAdapter.state.ipfsFileProviders + const serviceProviders = [] + for (let i = 0; i < initialServiceProviders.length; i++) { + const thisProvider = initialServiceProviders[i] + + const providerData = peerData.filter((x) => x.from === thisProvider) + + if (providerData.length) { + const provObj = { + ipfsId: thisProvider, + name: providerData[0].data.jsonLd.name + } + + serviceProviders.push(provObj) + } + } + + // console.log( + // `serviceProviders: ${JSON.stringify(serviceProviders, null, 2)}` + // ) + + const outObj = { + serviceProviders, + selectedProvider: this.ipfs.ipfsCoordAdapter.state.selectedIpfsFileProvider + } + // console.log('outObj: ', outObj) + + return outObj + } catch (err) { + // console.log('createUser() error: ', err) + wlogger.error('Error in adapters/files/getStatus()') + throw err + } + } + + // Choose the ipfs-file-pin-service to use. + async selectProvider (providerId) { + try { + this.ipfs.ipfsCoordAdapter.config.selectedIpfsFileProvider = providerId + + return true + } catch (err) { + // console.log('createUser() error: ', err) + wlogger.error('Error in adapters/files/getStatus()') + throw err + } + } + + // Given a CID, query the ipfs-file-pin-service to get the file metadata, like + // the file name. + async getFileMetadata (inObj = {}) { + try { + const { cid } = inObj + + // Input validation. + if (!cid || typeof cid !== 'string') { + throw new Error('getFileMetadata() cid input hash must be a string.') + } + + // Throw an error if this IPFS node has not yet made a connection to a + // wallet service provider. + const selectedProvider = + this.ipfs.ipfsCoordAdapter.state.selectedIpfsFileProvider + if (!selectedProvider) { + throw new Error('No IPFS File Pin Service provider available yet.') + } + + const rpcData = { + endpoint: 'getFileMetadata', + cid + } + + // Generate a UUID for the call. + const rpcId = this.uid() + + // Generate a JSON RPC command. + const cmd = this.jsonrpc.request(rpcId, 'file-pin', rpcData) + const cmdStr = JSON.stringify(cmd) + // console.log('cmdStr: ', cmdStr) + + // Send the RPC command to selected wallet service. + const thisNode = this.ipfs.ipfsCoordAdapter.ipfsCoord.thisNode + await this.ipfs.ipfsCoordAdapter.ipfsCoord.useCases.peer.sendPrivateMessage( + selectedProvider, + cmdStr, + thisNode + ) + + // Wait for data to come back from the wallet service. + const data = await this.waitForRPCResponse(rpcId) + // console.log('getFileMetadata() data: ', data) + + return data + } catch (err) { + wlogger.error('Error in adapters/files/getFileMetadata()') + throw err + } + } + + // Read an entry from the P2WDB, given an entry hash. + async getEntryByHash (hash) { + try { + // Input validation. + if (!hash || typeof hash !== 'string') { + throw new Error('getEntry() input hash must be a string.') + } + + // Throw an error if this IPFS node has not yet made a connection to a + // wallet service provider. + const selectedProvider = + this.ipfs.ipfsCoordAdapter.state.selectedP2wdbProvider + if (!selectedProvider) { + throw new Error('No P2WDB Service provider available yet.') + } + + const rpcData = { + endpoint: 'getByHash', + hash + } + + // Generate a UUID for the call. + const rpcId = this.uid() + + // Generate a JSON RPC command. + const cmd = this.jsonrpc.request(rpcId, 'p2wdb', rpcData) + const cmdStr = JSON.stringify(cmd) + // console.log('cmdStr: ', cmdStr) + + // Send the RPC command to selected wallet service. + const thisNode = this.ipfs.ipfsCoordAdapter.ipfsCoord.thisNode + await this.ipfs.ipfsCoordAdapter.ipfsCoord.useCases.peer.sendPrivateMessage( + selectedProvider, + cmdStr, + thisNode + ) + + // Wait for data to come back from the wallet service. + const data = await this.waitForRPCResponse(rpcId) + + return data + } catch (err) { + console.error('Error in getEntryByHash()') + throw err + } + } + + async writeEntry (writeObj) { + try { + const { txid, signature, message, data } = writeObj + + // Throw an error if this IPFS node has not yet made a connection to a + // wallet service provider. + const selectedProvider = + this.ipfs.ipfsCoordAdapter.state.selectedP2wdbProvider + if (!selectedProvider) { + throw new Error('No P2WDB Service provider available yet.') + } + + const rpcData = { + endpoint: 'write', + txid, + signature, + message, + data + } + + // Generate a UUID for the call. + const rpcId = this.uid() + + // Generate a JSON RPC command. + const cmd = this.jsonrpc.request(rpcId, 'p2wdb', rpcData) + const cmdStr = JSON.stringify(cmd) + // console.log('cmdStr: ', cmdStr) + + // Send the RPC command to selected wallet service. + const thisNode = this.ipfs.ipfsCoordAdapter.ipfsCoord.thisNode + await this.ipfs.ipfsCoordAdapter.ipfsCoord.useCases.peer.sendPrivateMessage( + selectedProvider, + cmdStr, + thisNode + ) + + // Wait for data to come back from the wallet service. + const result = await this.waitForRPCResponse(rpcId) + + return result + } catch (err) { + console.error('Error in adapters/p2wdb/writeEntry()') + throw err + } + } + + // Returns a promise that resolves to data when the RPC response is recieved. + async waitForRPCResponse (rpcId) { + try { + // Initialize variables for tracking the return data. + let dataFound = false + let cnt = 0 + + // Default return value, if the remote computer does not respond in time. + let data = { + success: false, + message: 'request timed out', + data: '' + } + + // Loop that waits for a response from the service provider. + do { + // console.log(`this.rpcDataQueue.length: ${this.rpcDataQueue.length}`) + for (let i = 0; i < this.rpcDataQueue.length; i++) { + const rawData = this.rpcDataQueue[i] + // console.log(`rawData: ${JSON.stringify(rawData, null, 2)}`) + + if (rawData.payload.id === rpcId) { + dataFound = true + // console.log('data was found in the queue') + + data = rawData.payload.result.value + + // Remove the data from the queue + this.rpcDataQueue.splice(i, 1) + + break + } + } + + // Wait between loops. + // await this.sleep(1000) + await this.ipfs.ipfsCoordAdapter.wallet.bchjs.Util.sleep(2000) + + cnt++ + + // Exit if data was returned, or the window for a response expires. + } while (!dataFound && cnt < 10) + // console.log(`dataFound: ${dataFound}, cnt: ${cnt}`) + + return data + } catch (err) { + console.error('Error in waitForRPCResponse()') + throw err + } + } +} + +// module.exports = P2wdbAdapter +export default IpfsFilesAdapter diff --git a/src/adapters/ipfs/ipfs-coord.js b/src/adapters/ipfs/ipfs-coord.js index 9e9451f..e07b025 100644 --- a/src/adapters/ipfs/ipfs-coord.js +++ b/src/adapters/ipfs/ipfs-coord.js @@ -23,6 +23,8 @@ const MIN_BCH_WALLET_VERSION = '1.11.11' const WALLET_PROTOCOL = 'bch-wallet' const MIN_P2WDB_VERSION = '1.4.0' const P2WDB_PROTOCOL = 'p2wdb' +const MIN_FILE_PIN_VERSION = '1.0.0' +const FILE_PIN_PROTOCOL = 'ipfs-file-pin-service' let _this @@ -56,9 +58,13 @@ class IpfsCoordAdapter { // Periodically poll services for available wallet service providers. this.pollBchServiceInterval = setInterval(this.pollForBchServices, 10000) + this.pollIpfsFileServiceInterval = setInterval( + this.pollForIpfsFileServices, + 11000 + ) this.pollP2wdbServiceInterval = setInterval( this.pollForP2wdbServices, - 11000 + 12000 ) // State object. TODO: Make this more robust. @@ -66,7 +72,9 @@ class IpfsCoordAdapter { serviceProviders: [], selectedServiceProvider: '', p2wdbProviders: [], - selectedP2wdbProvider: '' + selectedP2wdbProvider: '', + ipfsFileProviders: [], + selectedIpfsFileProvider: '' } _this = this @@ -308,6 +316,92 @@ class IpfsCoordAdapter { } } + // Poll the ipfs-coord coordination channel for available ipfs-file-pin-service + // providers. This method is called periodically by a timer-interval. + pollForIpfsFileServices () { + try { + // console.log('pollForIpfsFileServices() polling for BCH service') + + // An array of IPFS IDs of other nodes in the coordination pubsub channel. + const peers = _this.ipfsCoord.thisNode.peerList + // console.log(`peers: ${JSON.stringify(peers, null, 2)}`) + + // Array of objects. Each object is the IPFS ID of the peer and contains + // data about that peer. + const peerData = _this.ipfsCoord.thisNode.peerData + // console.log(`peerData: ${JSON.stringify(peerData, null, 2)}`) + + for (let i = 0; i < peers.length; i++) { + const thisPeer = peers[i] + const thisData = peerData.filter((x) => x.from === thisPeer) + const thisPeerData = thisData[0] + + // Create a 'fingerprint' that defines the wallet service. + const protocol = thisPeerData.data.jsonLd.protocol + const version = thisPeerData.data.jsonLd.version + // console.log( + // `debug: peer ${thisPeer} uses protocol: ${protocol} v${version}`, + // ) + + let versionMatches = false + if (version) { + // versionMatches = _this.semver.gt(version, MIN_FILE_PIN_VERSION) + versionMatches = _this.semver.satisfies(version, `>=${MIN_FILE_PIN_VERSION}`) + } + + // Ignore any peers that don't match the fingerprint for a BCH wallet + // service. + if (protocol && protocol.includes(FILE_PIN_PROTOCOL) && versionMatches) { + // console.log('Matching peer: ', thisPeerData) + + // Temporary business logic. + // Use the first available wallet service detected. + if (_this.state.ipfsFileProviders.length === 0) { + _this.state.selectedIpfsFileProvider = thisPeer + + // Persist the config setting, so it can be used by other commands. + // _this.conf.set('selectedService', thisPeer) + console.log(`---->IPFS File service selected: ${thisPeer}`) + } + + // console.log('preferredProvider: ', _this.config.preferredProvider) + + // If a preferred provider is set in the config file, then connect + // to the preferred provider when it's discovered. + if ( + _this.config.preferredIpfsFileProvider && + thisPeer === _this.config.preferredIpfsFileProvider + ) { + _this.state.selectedIpfsFileProvider = thisPeer + } + + // console.log('selectedServiceProvider: ', _this.state.selectedServiceProvider) + + // Check if the peer has already been added to the list of providers. + const alreadyExists = _this.state.ipfsFileProviders.filter( + (x) => x === thisPeer + ) + + // Add the peer to the list of serviceProviders if it doesn't already + // exist in the list. + if (!alreadyExists.length) { + _this.state.ipfsFileProviders.push(thisPeer) + } + } + } + } catch (err) { + // catch and handle known failure mode. + if ( + err.message.includes("Cannot read property 'peerList' of undefined") + ) { + return + } + + console.error('Error in pollForIpfsFileServices(): ', err) + // Do not throw error. This is a top-level function. + } + } + // This method handles input coming in from other IPFS peers. // It passes the data on to the REST API library by emitting an event. // peerInputHandler (data) { diff --git a/src/controllers/json-rpc/index.js b/src/controllers/json-rpc/index.js index 7d341bf..eb7b005 100644 --- a/src/controllers/json-rpc/index.js +++ b/src/controllers/json-rpc/index.js @@ -97,6 +97,7 @@ class JSONRPC { wlogger.info( `JSON RPC received from ${from}, ID: ${parsedData.payload.id}, type: ${parsedData.type}` ) + // console.log('parsedData: ', JSON.stringify(parsedData, null, 2)) } // console.log(`parsedData: ${JSON.stringify(parsedData, null, 2)}`) @@ -113,6 +114,11 @@ class JSONRPC { console.log('routing to BCH adapter') retObj = await _this.adapters.bch.rpcHandler(parsedData) } + + if (parsedData.payload.result.method === 'file-pin') { + console.log('routing to IPFS Files adapter') + retObj = await _this.adapters.ipfsFiles.rpcHandler(parsedData) + } } catch (err) { /* exit quietly */ } diff --git a/src/use-cases/ipfs-use-cases.js b/src/use-cases/ipfs-use-cases.js index 391827b..d5e7211 100644 --- a/src/use-cases/ipfs-use-cases.js +++ b/src/use-cases/ipfs-use-cases.js @@ -54,6 +54,8 @@ class IpfsUseCases { // throw new Error('File has not been pinned. Not available.') // } + await this.getCidMetadata({ cid }) + const helia = this.adapters.ipfs.ipfs // Convert the file to a Buffer. @@ -81,6 +83,24 @@ class IpfsUseCases { } } + // Given a CID, this function will retrieve file metadata from the + // ipfs-file-pin-service, using the IPFS JSON-RPC. This metadata includes + // the filename, which can be used to infer mime-type, so that it can be + // delivered to a web browser. + async getCidMetadata (inObj = {}) { + try { + const { cid } = inObj + + const ipfsFiles = this.adapters.ipfsFiles + + const metadata = await ipfsFiles.getFileMetadata({ cid }) + console.log('getCidMetadata() metadata: ', metadata) + } catch (err) { + console.error('Error in getCidMetadata(): ', err) + throw err + } + } + // async downloadCid2 (inObj = {}) { // try { // const { cid } = inObj