3box
Version:
Interact with user data
327 lines (284 loc) • 10.1 kB
JavaScript
const isIPFS = require('is-ipfs')
const API = require('./api')
const config = require('./config')
const { symEncryptBase, symDecryptBase, newSymKey } = require('./3id/utils')
const utils = require('./utils/index')
const orbitAddress = require('orbit-db/src/orbit-db-address')
const ORBITDB_OPTS = config.orbitdb_options
const MODERATOR = 'MODERATOR'
const MEMBER = 'MEMBER'
const isValid3ID = did => {
const parts = did.split(':')
if (!parts[0] === 'did' || !parts[1] === '3') return false
return isIPFS.cid(parts[2])
}
class Thread {
/**
* Please use **space.joinThread** to get the instance of this class
*/
constructor (name, replicator, members, firstModerator, confidential, user, subscribe) {
this._name = name
this._replicator = replicator
this._spaceName = name ? name.split('.')[2] : undefined
this._subscribe = subscribe
this._queuedNewPosts = []
this._members = Boolean(members)
this._firstModerator = firstModerator
this._user = user
if (confidential) {
this._confidential = true
this._members = true
if (typeof confidential === 'string') {
this._encKeyId = confidential
} else {
this._symKey = newSymKey()
this._encKeyId = utils.sha256(this._symKey)
}
}
}
/**
* Post a message to the thread
*
* @param {Object} message The message
* @return {String} The postId of the new post
*/
async post (message) {
this._requireLoad()
this._requireAuth()
this._subscribe(this._address, { firstModerator: this._firstModerator, members: this._members, name: this._name })
this._replicator.ensureConnected(this._address, true)
const timestamp = Math.floor(new Date().getTime() / 1000) // seconds
if (this._confidential) message = this._symEncrypt(message)
return this._db.add({
message,
timestamp
})
}
get address () {
return this._db ? this._address : null
}
async _getThreadAddress () {
if (this._address) return this._address
await this._initAcConfigs()
const address = (await this._replicator._orbitdb._determineAddress(this._name, 'feed', {
accessController: this._accessController
}, false)).toString()
this._address = address
return this._address
}
/**
* Add a moderator to this thread, throws error is user can not add a moderator
*
* @param {String} id Moderator Id
*/
async addModerator (id) {
this._requireLoad()
this._requireAuth()
if (id.startsWith('0x')) {
id = await API.getSpaceDID(id, this._spaceName)
}
if (!isValid3ID(id)) throw new Error('addModerator: must provide valid 3ID')
return this._db.access.grant(MODERATOR, id, await this._encryptSymKey(id))
}
/**
* List moderators
*
* @return {Array<String>} Array of moderator DIDs
*/
async listModerators () {
this._requireLoad()
return this._db.access.capabilities.moderators
}
/**
* Add a member to this thread, throws if user can not add member, throw is not member thread
*
* @param {String} id Member Id
*/
async addMember (id) {
this._requireLoad()
this._requireAuth()
this._throwIfNotMembers()
if (id.startsWith('0x')) {
id = await API.getSpaceDID(id, this._spaceName)
}
if (!isValid3ID(id)) throw new Error('addMember: must provide valid 3ID')
return this._db.access.grant(MEMBER, id, await this._encryptSymKey(id))
}
/**
* List members, throws if not member thread
*
* @return {Array<String>} Array of member DIDs
*/
async listMembers () {
this._throwIfNotMembers()
this._requireLoad()
return this._db.access.capabilities.members
}
_throwIfNotMembers () {
if (!this._members) throw new Error('Thread: Not a members only thread, function not available')
}
/**
* Delete post
*
* @param {String} id Moderator Id
*/
async deletePost (hash) {
this._requireLoad()
this._requireAuth()
return this._db.remove(hash)
}
/**
* Returns an array of posts, based on the options.
* If hash not found when passing gt, gte, lt, or lte,
* the iterator will return all items (respecting limit and reverse).
*
* @param {Object} opts Optional parameters
* @param {String} opts.gt Greater than, takes an postId
* @param {String} opts.gte Greater than or equal to, takes an postId
* @param {String} opts.lt Less than, takes an postId
* @param {String} opts.lte Less than or equal to, takes an postId
* @param {Integer} opts.limit Limiting the number of entries in result, defaults to -1 (no limit)
* @param {Boolean} opts.reverse If set to true will result in reversing the result
*
* @return {Array<Object>} true if successful
*/
async getPosts (opts = {}) {
const decrypt = (entry) => {
if (!this._confidential) return entry
const message = this._symDecrypt(entry.message)
return { message, timestamp: entry.timestamp }
}
this._requireLoad()
if (!opts.limit) opts.limit = -1
return this._db.iterator(opts).collect().map(entry => {
const post = decrypt(entry.payload.value)
const metaData = { postId: entry.hash, author: entry.identity.id }
return Object.assign(metaData, post)
})
}
/**
* Register a function to be called after new updates
* have been received from the network or locally.
*
* @param {Function} updateFn The function that will get called
*/
async onUpdate (updateFn) {
this._requireLoad()
this._db.events.on('replicated', (address, hash, entry, prog, tot) => {
updateFn()
})
this._db.events.on('write', (dbname, entry) => {
updateFn()
})
}
/**
* Register a function to be called for every new
* capability that is added to the thread access controller.
* This inlcudes when a moderator or member is added.
* The function takes one parameter, which is the capabilities obj, or
* you can call listModerator / listMembers again instead.
*
* @param {Function} updateFn The function that will get called
*/
async onNewCapabilities (updateFn) {
this._db.access.on('updated', event => {
updateFn(this._db.access.capabilities)
})
}
// Loads by orbitdb address or db name
async _load (dbString) {
const loadByAddress = dbString && orbitAddress.isValid(dbString)
if (!loadByAddress) await this._initAcConfigs()
this._db = await this._replicator._orbitdb.feed(dbString || this._name, {
...ORBITDB_OPTS,
accessController: this._accessController
})
await this._db.load()
if (loadByAddress) {
this._firstModerator = this._db.access._firstModerator
this._members = this._db.access._members
this._encKeyId = this._db.access._encKeyId
this._confidential = Boolean(this._db.access._encKeyId)
this._name = this._db.address.path
this._spaceName = this._name.split('.')[2]
}
this._address = this._db.address.toString()
this._replicator.ensureConnected(this._address, true)
return this._address
}
async _initConfidential () {
if (this._symKey) {
if (this._user.DID !== this._firstModerator) throw new Error('_initConfidential: firstModerator must initialize a confidential thread')
await this._db.access.grant(MODERATOR, this._firstModerator, await this._encryptSymKey())
} else {
let encryptedKey = null
try {
encryptedKey = this._db.access.getEncryptedKey(this._user.DID)
} catch (e) {
encryptedKey = await new Promise((resolve, reject) => {
this.onNewCapabilities((val) => {
let key = null
try {
key = this._db.access.getEncryptedKey(this._user.DID)
} catch (e) { }
if (key !== null) resolve(key)
})
setTimeout(() => resolve(null), 10000)
})
}
if (!encryptedKey) throw new Error(`_initConfidential: no access for ${this._user.DID}`)
this._symKey = await this._decryptSymKey(encryptedKey)
}
}
_requireLoad () {
if (!this._db) throw new Error('_load must be called before interacting with the store')
}
_requireAuth () {
if (!this._authenticated) throw new Error('You must authenticate before performing this action')
}
async close () {
this._requireLoad()
await this._db.close()
}
async _setIdentity (odbId) {
this._db.setIdentity(odbId)
this._db.access._db.setIdentity(odbId)
this._authenticated = true
// TODO not too clear hear, but does require auth, and to be after load
if (this._confidential) {
await this._initConfidential()
}
}
async _initAcConfigs () {
if (this._accessController) return
if (this._firstModerator.startsWith('0x')) {
this._firstModerator = await API.getSpaceDID(this._firstModerator, this._spaceName)
}
this._accessController = {
type: 'thread-access',
threadName: this._name,
members: this._members,
firstModerator: this._firstModerator
}
if (this._encKeyId) {
this._accessController.encKeyId = this._encKeyId
}
}
_symEncrypt (message) {
const msg = utils.pad(JSON.stringify(message))
return symEncryptBase(msg, this._symKey)
}
_symDecrypt (payload) {
const paddedMsg = symDecryptBase(payload.ciphertext, this._symKey, payload.nonce)
return JSON.parse(utils.unpad(paddedMsg))
}
async _encryptSymKey (to) {
if (!this._confidential) return null
return this._user.encrypt(this._symKey, { to })
}
async _decryptSymKey (encKey) {
const key = await this._user.decrypt(encKey, true)
return new Uint8Array(key)
}
}
module.exports = Thread