UNPKG

3box

Version:
411 lines (383 loc) 15.2 kB
const KeyValueStore = require('./keyValueStore') const Thread = require('./thread') const GhostThread = require('./ghost') const API = require('./api') const { throwIfUndefined, throwIfNotEqualLenArrays } = require('./utils') const OrbitDBAddress = require('orbit-db/src/orbit-db-address') const nameToSpaceName = name => `3box.space.${name}.keyvalue` const namesTothreadName = (spaceName, threadName) => `3box.thread.${spaceName}.${threadName}` const namesToChatName = (spaceName, chatName) => `3box.ghost.${spaceName}.${chatName}` /** Class representing a user. */ class User { constructor (spaceName, threeId, resolver) { this._name = spaceName this._3id = threeId this._resolver = resolver } /** * @property {String} DID the DID of the user */ get DID () { return this._3id.getSubDID(this._name) } /** * Sign a JWT claim * * @param {Object} payload The payload to sign * @param {Object} opts Optional parameters * * @return {String} The signed JWT */ async signClaim (payload, opts = {}) { return this._3id.signJWT(payload, Object.assign(opts, { space: this._name })) } /** * Encrypt a message. By default encrypts messages symmetrically * with the users private key. If the `to` parameter is used, * the message will be asymmetrically encrypted to the recipient. * * @param {String} message The message to encrypt * @param {Object} opts Optional parameters * @param {String} to The receiver of the message, a DID or an ethereum address * * @return {Object} An object containing the encrypted payload */ async encrypt (message, { to } = {}) { let toPubkey if (to) { toPubkey = await this._findSpacePubKey(to, this._name) } return this._3id.encrypt(message, this._name, toPubkey) } /** * Decrypts a message if the user owns the correct key to decrypt it. * * @param {Object} encryptedObject The encrypted message to decrypt (as encoded by the `encrypt` method * * @return {String} The clear text message */ async decrypt (encryptedObject, toBuffer) { return this._3id.decrypt(encryptedObject, this._name, toBuffer) } async _findSpacePubKey (did, spaceName) { if (did.startsWith('0x')) { // we got an ethereum address did = await API.getSpaceDID(did, spaceName) } let doc = await this._resolver.resolve(did) let pubkey = doc.publicKey.find(key => key.id.includes('#subEncryptionKey')) if (!pubkey) { // A root 3ID was passed, get the space 3ID did = await API.getSpaceDID(did, spaceName) doc = await this._resolver.resolve(did) pubkey = doc.publicKey.find(key => key.id.includes('#subEncryptionKey')) } return pubkey.publicKeyBase64 } } class Space { /** * Please use **box.openSpace** to get the instance of this class */ constructor (name, replicator) { this._name = name this._replicator = replicator this._store = new KeyValueStore(nameToSpaceName(this._name), this._replicator) this._activeThreads = {} /** * @property {KeyValueStore} public access the profile store of the space */ this.public = null /** * @property {KeyValueStore} private access the private store of the space */ this.private = null /** * @property {Promise} syncDone A promise that is resolved when the space data is synced */ this.syncDone = null } get DID () { return this.user.DID } /** * @property {User} user access the user object to encrypt data and sign claims */ get user () { if (!this._3id) throw new Error('user is not authenticated') this._user = this._user || new User(this._name, this._3id, this._replicator.resolver) return this._user } get isOpen () { return Boolean(this._store._db) } async open (threeId, opts = {}) { if (!this.isOpen) { // store is not loaded opened yet this._3id = threeId const authenticated = await this._3id.isAuthenticated([this._name]) if (!authenticated) { await this._3id.authenticate([this._name], opts) } if (opts.consentCallback) opts.consentCallback(!authenticated, this._name) await this._store._load(this._3id) const syncSpace = async () => { await this._store._sync() if (opts.onSyncDone) opts.onSyncDone() } this.syncDone = syncSpace() this.public = publicStoreReducer(this._store) this.private = privateStoreReducer(this._store, this._3id, this._name) // make sure we're authenticated to all threads await this._authThreads(this._3id) } } async _authThreads (threeId) { const odbIdentity = await threeId.getOdbId(this._name) Object.values(this._activeThreads).forEach(thread => { if (thread.isGhost) { thread._set3id(threeId) } else { thread._setIdentity(odbIdentity) } }) } /** * Join a thread. Use this to start receiving updates from, and to post in threads * * @param {String} name The name of the thread * @param {Object} opts Optional parameters * @param {String} opts.firstModerator DID of first moderator of a thread, by default, user is first moderator * @param {Boolean} opts.members join a members only thread, which only members can post in, defaults to open thread * @param {Boolean} opts.confidential create a confidential thread with true or join existing confidential thread with an encKeyId string * @param {Boolean} opts.noAutoSub Disable auto subscription to the thread when posting to it (default false) * @param {Boolean} opts.ghost Enable ephemeral messaging via Ghost Thread * @param {Number} opts.ghostBacklogLimit The number of posts to maintain in the ghost backlog * @param {Array<Function>} opts.ghostFilters Array of functions for filtering messages * * @return {Thread} An instance of the thread class for the joined thread */ async joinThread (name, opts = {}) { if (opts.ghost) { const ghostAddress = namesToChatName(this._name, name) if (!this._activeThreads[ghostAddress]) { this._activeThreads[ghostAddress] = new GhostThread(ghostAddress, this._replicator, this._3id, opts) } if (this._3id) { this._activeThreads[ghostAddress]._set3id(this._3id) } return this._activeThreads[ghostAddress] } else { const subscribeFn = opts.noAutoSub ? () => {} : this.subscribeThread.bind(this) if (opts.confidential) { if (!this._3id) throw new Error('confidential threads require user to be authenticated') } if (!opts.firstModerator) { if (!this._3id) throw new Error('firstModerator required if not authenticated') opts.firstModerator = this._3id.getSubDID(this._name) } const user = this._3id ? this.user : {} const thread = new Thread(namesTothreadName(this._name, name), this._replicator, opts.members, opts.firstModerator, opts.confidential, user, subscribeFn) const address = await thread._getThreadAddress() if (this._activeThreads[address]) return this._activeThreads[address] await thread._load() if (this._3id) { await thread._setIdentity(await this._3id.getOdbId(this._name)) } this._activeThreads[address] = thread return thread } } /** * Create a confidential thread * * @param {String} name The name of the thread * * @return {Thread} An instance of the thread class for the created thread */ async createConfidentialThread (name) { return this.joinThread(name, { confidential: true }) } /** * Join a thread by full thread address. Use this to start receiving updates from, and to post in threads * * @param {String} address The full address of the thread * @param {Object} opts Optional parameters * @param {Boolean} opts.noAutoSub Disable auto subscription to the thread when posting to it (default false) * * @return {Thread} An instance of the thread class for the joined thread */ async joinThreadByAddress (address, opts = {}) { if (!OrbitDBAddress.isValid(address)) throw new Error('joinThreadByAddress: valid orbitdb address required') if (!this.isOpen) throw new Error('joinThreadByAddress requires space to be open') const threadSpace = address.split('.')[2] const threadName = address.split('.')[3] if (threadSpace !== this._name) throw new Error('joinThreadByAddress: attempting to open thread from different space, must open within same space') if (this._activeThreads[address]) return this._activeThreads[address] const subscribeFn = opts.noAutoSub ? () => {} : this.subscribeThread.bind(this) const user = this._3id ? this.user : {} const thread = new Thread(namesTothreadName(this._name, threadName), this._replicator, undefined, undefined, undefined, user, subscribeFn) await thread._load(address) if (this._3id) { await thread._setIdentity(await this._3id.getOdbId(this._name)) } this._activeThreads[address] = thread return thread } /** * Subscribe to the given thread, if not already subscribed * * @param {String} address The address of the thread * @param {Object} config configuration and thread meta data * @param {String} opts.name Name of thread * @param {String} opts.firstModerator DID of the first moderator * @param {String} opts.members Boolean string, true if a members only thread */ async subscribeThread (address, config = {}) { if (!OrbitDBAddress.isValid(address)) throw new Error('subscribeThread: must subscribe to valid thread/orbitdb address') if (!this.isOpen) return // we can't subscribe if space isn't open const threadKey = `thread-${address}` await this.syncDone if (!(await this.public.get(threadKey))) { await this.public.set(threadKey, Object.assign({}, config, { address })) } } /** * Unsubscribe from the given thread, if subscribed * * @param {String} address The address of the thread */ async unsubscribeThread (address) { if (!this.isOpen) throw new Error('unsubscribeThread requires space to be open') const threadKey = `thread-${address}` if (await this.public.get(threadKey)) { await this.public.remove(threadKey) } } /** * Get a list of all the threads subscribed to in this space * * @return {Array<Objects>} A list of thread objects as { address, firstModerator, members, name} */ async subscribedThreads () { if (!this.isOpen) throw new Error('subscribedThreads requires space to be open') const allEntries = await this.public.all() return Object.keys(allEntries).reduce((threads, key) => { if (key.startsWith('thread')) { // ignores experimental threads (v1) const address = key.split('thread-')[1] if (OrbitDBAddress.isValid(address)) { threads.push(allEntries[key]) } } return threads }, []) } } module.exports = Space const publicStoreReducer = (store) => { const PREFIX = 'pub_' return { get: async (key, opts = {}) => store.get(PREFIX + key, opts), getMetadata: async key => store.getMetadata(PREFIX + key), set: async (key, value) => { throwIfUndefined(key, 'key') return store.set(PREFIX + key, value) }, setMultiple: async (keys, values) => { throwIfNotEqualLenArrays(keys, values) const prefixedKeys = keys.map(key => PREFIX + key) return store.setMultiple(prefixedKeys, values) }, remove: async key => { throwIfUndefined(key, 'key') return store.remove(PREFIX + key) }, async log () { return (await store.log()).reduce((newLog, entry) => { if (entry.key.startsWith(PREFIX)) { entry.key = entry.key.slice(4) newLog.push(entry) } return newLog }, []) }, all: async (opts) => { const entries = await store.all(opts) return Object.keys(entries).reduce((newAll, key) => { if (key.startsWith(PREFIX)) { newAll[key.slice(4)] = entries[key] } return newAll }, {}) } } } const privateStoreReducer = (store, threeId, spaceName) => { const PREFIX = 'priv_' const dbKey = async key => { throwIfUndefined(key, 'key') return PREFIX + await threeId.hashDBKey(key, spaceName) } const encryptEntry = async entry => threeId.encrypt(JSON.stringify(entry), spaceName) const decryptEntry = async encObj => JSON.parse(await threeId.decrypt(encObj, spaceName)) return { get: async (key, opts = {}) => { const entry = await store.get(await dbKey(key), opts) if (!entry) { return null } if (opts.metadata) { return { ...entry, value: (await decryptEntry(entry.value)).value } } return (await decryptEntry(entry)).value }, getMetadata: async key => store.getMetadata(await dbKey(key)), set: async (key, value) => store.set(await dbKey(key), await encryptEntry({ key, value })), setMultiple: async (keys, values) => { throwIfNotEqualLenArrays(keys, values) const dbKeys = await Promise.all(keys.map(dbKey)) const encryptedEntries = await Promise.all( values.map((value, index) => encryptEntry({ key: keys[index], value })) ) return store.setMultiple(dbKeys, encryptedEntries) }, remove: async key => store.remove(await dbKey(key)), async log () { const log = await store.log() const privLog = [] for (const entry of log) { if (entry.key.startsWith(PREFIX)) { const decEntry = await decryptEntry(entry.value) entry.key = decEntry.key entry.value = decEntry.value privLog.push(entry) } } return privLog }, all: async (opts = {}) => { const entries = await store.all(opts) const privEntries = {} for (const key in entries) { if (key.startsWith(PREFIX)) { const entry = entries[key] if (opts.metadata) { const decEntry = await decryptEntry(entry.value) privEntries[decEntry.key] = { ...entry, value: decEntry.value } } else { const decEntry = await decryptEntry(entry) privEntries[decEntry.key] = decEntry.value } } } return privEntries } } }