UNPKG

3box

Version:
355 lines (321 loc) 13.2 kB
const path = require('path') const EventEmitter = require('events') const merge = require('lodash.merge') const OrbitDB = require('orbit-db') const Pubsub = require('orbit-db-pubsub') const AccessControllers = require('orbit-db-access-controllers') const OdbStorage = require('orbit-db-storage-adapter') const OdbCache = require('orbit-db-cache') const OdbKeystore = require('orbit-db-keystore') const { Resolver } = require('did-resolver') const get3IdResolver = require('3id-resolver').getResolver const getMuportResolver = require('muport-did-resolver').getResolver const { OdbIdentityProvider, LegacyIPFS3BoxAccessController, ThreadAccessController, ModeratorAccessController } = require('3box-orbitdb-plugins') AccessControllers.addAccessController({ AccessController: LegacyIPFS3BoxAccessController }) AccessControllers.addAccessController({ AccessController: ThreadAccessController }) AccessControllers.addAccessController({ AccessController: ModeratorAccessController }) const config = require('./config') const Identities = require('orbit-db-identity-provider') Identities.addIdentityProvider(OdbIdentityProvider) const PINNING_NODE = config.pinning_node const PINNING_ROOM = config.pinning_room const ORBITDB_OPTS = config.orbitdb_options const entryTypes = { SPACE: 'space', ADDRESS_LINK: 'address-link', AUTH_DATA: 'auth-data' } class Replicator { constructor (ipfs, opts) { this.events = new EventEmitter() this.ipfs = ipfs this._pinningNode = opts.pinningNode || PINNING_NODE this.ipfs.swarm.connect(this._pinningNode, () => {}) this._stores = {} this._storePromises = {} // TODO - this should only be done in 3box-js. For use in // 3box-pinning-node the below code should be disabled this._hasPubsubMsgs = {} const threeIdResolver = get3IdResolver(ipfs, { pin: true }) const muportResolver = getMuportResolver(ipfs) this.resolver = new Resolver({ ...threeIdResolver, ...muportResolver }) OdbIdentityProvider.setDidResolver(this.resolver) this._orbitDbOpts = { ...ORBITDB_OPTS, format: 'dag-pb', accessController: { type: 'legacy-ipfs-3box', skipManifest: true, resolver: this.resolver } } this.events.on('pinning-room-message', (topic, data) => { if (data.type === 'HAS_ENTRIES' && data.odbAddress) { const odbAddress = data.odbAddress if (this._pinningRoomFilter) { const hasMsgFor = Object.keys(this._hasPubsubMsgs) if (this._pinningRoomFilter.length <= hasMsgFor.length) { const haveAllMsgs = this._pinningRoomFilter.reduce((acc, addr) => acc && hasMsgFor.includes(addr), true) if (haveAllMsgs) { this._pubsub.unsubscribe(PINNING_ROOM) } } // Before the pinning room filter is created we will keep all has messages // in memory. After we only care about the ones that are relevant. if (!this._pinningRoomFilter.includes(odbAddress)) { return } } this._hasPubsubMsgs[odbAddress] = data this.events.emit(`has-${odbAddress}`, data) } }) } _initPinningRoomFilter () { this._pinningRoomFilter = this.listStoreAddresses() // clear out any messages that are not relevant for (const odbAddress in this._hasPubsubMsgs) { if (!this._pinningRoomFilter.includes(odbAddress)) { delete this._hasPubsubMsgs[odbAddress] } } } async _init (opts) { this._pubsub = new Pubsub(this.ipfs, (await this.ipfs.id()).id) // Passes default cache but with fixed path instead of path based on // orbitdb/ipfs id which can change on page load const cachePath = path.join(opts.orbitPath || './orbitdb', '/cache') const levelDown = OdbStorage(null, {}) const cacheStorage = await levelDown.createStore(cachePath) const cache = new OdbCache(cacheStorage) const keystorePath = path.join(opts.orbitPath || './orbitdb', '/keystore') const keyStorage = await levelDown.createStore(keystorePath) const keystore = new OdbKeystore(keyStorage) // Identity not used, passes ref to 3ID orbit identity provider const identity = await Identities.createIdentity({ id: 'nullid', keystore: keystore }) this._orbitdb = await OrbitDB.createInstance(this.ipfs, { directory: opts.orbitPath, identity, cache, keystore }) } async _joinPinningRoom (firstJoin) { if (!firstJoin && (await this.ipfs.pubsub.ls()).includes(PINNING_ROOM)) return this._pubsub.subscribe(PINNING_ROOM, (topic, data) => { // console.log('message', topic, data) this.events.emit('pinning-room-message', topic, data) }, (topic, peer) => { // console.log('peer', topic, peer) this.events.emit('pinning-room-peer', topic, peer) }) } static async create (ipfs, opts = {}) { const replicator = new Replicator(ipfs, opts) await replicator._init(opts) return replicator } async start (rootstoreAddress, did, opts = {}) { this._did = did await this._joinPinningRoom(true) this._publishDB({ odbAddress: rootstoreAddress }) this.rootstore = await this._orbitdb.feed(rootstoreAddress, this._orbitDbOpts) await this.rootstore.load() this.rootstoreSyncDone = this.syncDB(this.rootstore) const waitForSync = async () => { await this.rootstoreSyncDone const addressLinkPinPromise = this.getAddressLinks() const authDataPinPromise = this.getAuthData() this._initPinningRoomFilter() await this._loadStores(opts) await Promise.all(Object.keys(this._stores).map(addr => this.syncDB(this._stores[addr]))) await addressLinkPinPromise await authDataPinPromise } this.syncDone = waitForSync() } async new (rootstoreName, pubkey, did) { if (this.rootstore) throw new Error('This method can only be called once before the replicator has started') this._did = did await this._joinPinningRoom(true) const orbitDbOpts = merge({}, this._orbitDbOpts, { accessController: { write: [pubkey] } }) this.rootstore = await this._orbitdb.feed(rootstoreName, orbitDbOpts) this._pinningRoomFilter = [] this._publishDB({ odbAddress: this.rootstore.address.toString() }) await this.rootstore.load() this.rootstoreSyncDone = Promise.resolve() this.syncDone = Promise.resolve() } async stop () { await this._orbitdb.stop() await this._pubsub.disconnect() } async addKVStore (name, pubkey, isSpace, did) { if (!this.rootstore) throw new Error('This method can only be called once before the replicator has started') const orbitDbOpts = merge({}, this._orbitDbOpts, { accessController: { write: [pubkey] } }) const store = await this._orbitdb.keyvalue(name, orbitDbOpts) const storeAddress = store.address.toString() this._stores[storeAddress] = store // add entry to rootstore await this.rootstoreSyncDone const entries = await this.rootstore.iterator({ limit: -1 }).collect() const entry = entries.find(entry => entry.payload.value.odbAddress === storeAddress) if (isSpace) { if (!entry) { await this.rootstore.add({ type: entryTypes.SPACE, DID: did, odbAddress: storeAddress }) } else if (!entry.payload.value.type) { await this.rootstore.del(entry.hash) await this.rootstore.add({ type: entryTypes.SPACE, DID: did, odbAddress: storeAddress }) } } else if (!entry) { await this.rootstore.add({ odbAddress: storeAddress }) } if (!this._hasPubsubMsgs[storeAddress]) { this._hasPubsubMsgs[storeAddress] = { numEntries: 0 } } return store } async _loadStores ({ profile, allSpaces, spacesList }) { const storeEntries = this._listStoreEntries() const loadPromises = storeEntries.map(entry => { const data = entry.payload.value if (data.type === entryTypes.SPACE && data.DID) { this._pinDID(data.DID) } if (profile && (data.odbAddress.includes('public') || data.odbAddress.includes('private'))) { return this._loadKeyValueStore(data.odbAddress) } else if (data.odbAddress.includes(entryTypes.SPACE) && (allSpaces || (spacesList && spacesList.includes(data.odbAddress.split('.')[2])))) { return this._loadKeyValueStore(data.odbAddress) } }) return Promise.all(loadPromises) } async _loadKeyValueStore (odbAddress) { if (!this._storePromises[odbAddress]) { this._storePromises[odbAddress] = new Promise((resolve, reject) => { this._orbitdb.keyvalue(odbAddress, this._orbitDbOpts).then(store => { store.load().then(() => { resolve(store) }) }) }) } this._stores[odbAddress] = await this._storePromises[odbAddress] return this._stores[odbAddress] } async getStore (odbAddress) { return this._stores[odbAddress] || this._loadKeyValueStore(odbAddress) } listStoreAddresses () { return this._listStoreEntries().map(entry => entry.payload.value.odbAddress) } _listStoreEntries () { const entries = this.rootstore.iterator({ limit: -1 }).collect().filter(e => OrbitDB.isValidAddress(e.payload.value.odbAddress || '')) const uniqueEntries = entries.filter((e1, i, a) => { return a.findIndex(e2 => e2.payload.value.odbAddress === e1.payload.value.odbAddress) === i }) return uniqueEntries } async getAddressLinks () { const entries = await this.rootstore.iterator({ limit: -1 }).collect() const linkEntries = entries.filter(e => e.payload.value.type === entryTypes.ADDRESS_LINK) const resolveLinks = linkEntries.map(async entry => { const cid = entry.payload.value.data // TODO handle missing ipfs obj??, timeouts? const obj = (await this.ipfs.dag.get(cid)).value this.ipfs.pin.add(cid) obj.entry = entry return obj }) return Promise.all(resolveLinks) } async getAuthData () { const entries = await this.rootstore.iterator({ limit: -1 }).collect() const authEntries = entries.filter(e => e.payload.value.type === entryTypes.AUTH_DATA) const resolveLinks = authEntries.map(async entry => { const cid = entry.payload.value.data // TODO handle missing ipfs obj??, timeouts? const obj = (await this.ipfs.dag.get(cid)).value this.ipfs.pin.add(cid) obj.entry = entry return obj }) return Promise.all(resolveLinks) } get _pinningNodePeerId () { return this._pinningNode.split('/').pop() } async ensureConnected (odbAddress) { const isThread = odbAddress.includes('thread') const roomPeers = await this.ipfs.pubsub.peers(odbAddress) if (!roomPeers.find(p => p === this._pinningNodePeerId)) { this.ipfs.swarm.connect(this._pinningNode, () => {}) odbAddress = isThread ? odbAddress : this.rootstore.address.toString() this._publishDB({ odbAddress, isThread }, true) } } async _publishDB ({ odbAddress, isThread }, unsubscribe) { this._joinPinningRoom() odbAddress = odbAddress || this.rootstore.address.toString() // make sure that the pinning node is in the pubsub room before publishing const pinningNodeJoined = new Promise((resolve, reject) => { this.events.on('pinning-room-peer', (topic, peer) => { if (peer === this._pinningNodePeerId) { resolve() } }) }) if (!(await this.ipfs.pubsub.peers(PINNING_ROOM)).includes(this._pinningNodePeerId)) { await pinningNodeJoined } this._pubsub.publish(PINNING_ROOM, { type: isThread ? 'SYNC_DB' : 'PIN_DB', odbAddress, did: this._did, thread: isThread }) this.events.removeAllListeners('pinning-room-peer') if (unsubscribe) { this._pubsub.unsubscribe(PINNING_ROOM) } } async _getNumEntries (odbAddress) { return new Promise((resolve, reject) => { const eventName = `has-${odbAddress}` this.events.on(eventName, data => { this.events.removeAllListeners(eventName) resolve(data.numEntries) }) if (this._hasPubsubMsgs[odbAddress]) { this.events.removeAllListeners(eventName) resolve(this._hasPubsubMsgs[odbAddress].numEntries) } }) } async syncDB (dbInstance) { // TODO - syncDB is only relevant in 3box-js. Some different logic // is needed for syncing in 3box-pinning-node const numRemoteEntries = await this._getNumEntries(dbInstance.address.toString()) const isNumber = typeof numRemoteEntries === 'number' if (isNumber && numRemoteEntries <= dbInstance._oplog.values.length) return Promise.resolve() await new Promise((resolve, reject) => { dbInstance.events.on('replicated', () => { if (numRemoteEntries <= dbInstance._oplog.values.length) { resolve() dbInstance.events.removeAllListeners('replicated') } }) }) } static get entryTypes () { return entryTypes } async _pinDID (did) { if (!did) return // We resolve the DID in order to pin the ipfs object try { await this.resolver.resolve(did) // if this throws it's not a DID } catch (e) {} } } module.exports = Replicator