diff --git a/index.js b/index.js index 2efaad4..3d574f6 100644 --- a/index.js +++ b/index.js @@ -6,7 +6,7 @@ const { promisify } = require('util') const pull = require('pull-stream') const paraMap = require('pull-paramap') const pullMany = require('pull-many') -const lodashGet = require('lodash.get') +const pullDefer = require('pull-defer') const chunk = require('lodash.chunk') const clarify = require('clarify-error') const { @@ -319,137 +319,159 @@ module.exports = { } function listMembers(groupId, opts = {}) { - return pull( - pull.values([0]), - pull.asyncMap((n, cb) => { - get(groupId, (err, group) => { - // prettier-ignore - if (err) return cb(clarify(err, 'Failed to get group info when listing members')) + const deferedSource = pullDefer.source() - if (group.excluded) { - return cb( - new Error("We're excluded from this group, can't list members") - ) - } else { - const source = pull( - ssb.db.query( - where( - and( - isDecrypted('box2'), - type('group/add-member'), - groupRecp(groupId) - ) - ), - opts.live ? live({ old: true }) : null, - toPullStream() - ), - pull.map((msg) => msg.value.content.recps.slice(1)), - pull.flatten(), - pull.unique() + get(groupId, (err, group) => { + // prettier-ignore + if (err) return deferedSource.abort(clarify(err, 'Failed to get group info when listing members')) + // prettier-ignore + if (group.excluded) return deferedSource.abort( new Error("We're excluded from this group, can't list members")) + + const source = pull( + ssb.db.query( + where( + and( + isDecrypted('box2'), + type('group/add-member'), + groupRecp(groupId) ) - return cb(null, source) - } - }) - }), - pull.flatten() - ) + ), + opts.live ? live({ old: true }) : null, + toPullStream() + ), + pull.map((msg) => msg.value.content.recps.slice(1)), + pull.flatten(), + pull.unique() + ) + + deferedSource.resolve(source) + }) + + return deferedSource } function listInvites() { - return pull( - pull.values([0]), // dummy value used to kickstart the stream - pull.asyncMap((n, cb) => { - ssb.metafeeds.findOrCreate((err, myRoot) => { - // prettier-ignore - if (err) return cb(clarify(err, 'Failed to get root metafeed when listing invites')) + const deferedSource = pullDefer.source() - pull( - pullMany([ - ssb.box2.listGroupIds(), - ssb.box2.listGroupIds({ excluded: true }), - ]), - pull.flatten(), - pull.collect((err, groupIds) => { - // prettier-ignore - if (err) return cb(clarify(err, 'Failed to list group IDs when listing invites')) - - const source = pull( - ssb.db.query( - where(and(isDecrypted('box2'), type('group/add-member'))), - toPullStream() - ), - pull.filter((msg) => - // it's an addition of us - lodashGet(msg, 'value.content.recps', []).includes( - myRoot.id - ) - ), - pull.filter( - (msg) => - // we haven't already accepted the addition - !groupIds.includes( - lodashGet(msg, 'value.content.recps[0]') - ) - ), - pull.map((msg) => { - const key = Buffer.from( - lodashGet(msg, 'value.content.groupKey'), - 'base64' - ) - const scheme = keySchemes.private_group - return { - id: lodashGet(msg, 'value.content.recps[0]'), - writeKey: { key, scheme }, - readKeys: [{ key, scheme }], - root: lodashGet(msg, 'value.content.root'), - } - }) - ) + getMyGroups((err, myGroups) => { + // prettier-ignore + if (err) return deferedSource.abort(clarify(err, 'Failed to list group IDs when listing invites')) - return cb(null, source) - }) - ) - }) - }), - pull.flatten() - ) + const source = pull( + // get all the groupIds we've heard of from invites + ssb.db.query( + where(and(isDecrypted('box2'), type('group/add-member'))), + toPullStream() + ), + pull.filter((msg) => isAddMember(msg)), + pull.map((msg) => msg.value.content.recps[0]), + pull.unique(), + + // drop those we're a part of already + pull.filter((groupId) => !myGroups.has(groupId)), + + // gather all the data required for each group-invite + pull.asyncMap(getGroupInviteData) + ) + + deferedSource.resolve(source) + }) + + return deferedSource + + // listInvites helpers + + function getMyGroups(cb) { + const myGroups = new Set() + + pull( + pullMany([ + ssb.box2.listGroupIds(), + ssb.box2.listGroupIds({ excluded: true }), + ]), + pull.flatten(), + pull.drain( + (groupId) => myGroups.add(groupId), + (err) => { + if (err) return cb(err) + return cb(null, myGroups) + } + ) + ) + } + + function getGroupInviteData(groupId, cb) { + let root + const secrets = new Set() + + pull( + ssb.db.query( + where( + and( + isDecrypted('box2'), + type('group/add-member'), + groupRecp(groupId) + ) + ), + toPullStream() + ), + pull.filter((msg) => isAddMember(msg)), + pull.drain( + (msg) => { + root ||= msg.value.content.root + secrets.add(msg.value.content.groupKey) + }, + (err) => { + if (err) return cb(err) + + const readKeys = [...secrets].map((secret) => ({ + key: Buffer.from(secret, 'base64'), + scheme: keySchemes.private_group, + })) + const invite = { + id: groupId, + root, + readKeys, + } + return cb(null, invite) + } + ) + ) + } } function acceptInvite(groupId, cb) { if (cb === undefined) return promisify(acceptInvite)(groupId) - let foundInvite = false pull( listInvites(), - pull.filter((groupInfo) => groupInfo.id === groupId), + pull.filter((inviteInfo) => inviteInfo.id === groupId), pull.take(1), - pull.drain( - (groupInfo) => { - foundInvite = true - ssb.box2.addGroupInfo( - groupInfo.id, - { - key: groupInfo.writeKey.key, - root: groupInfo.root, - }, - (err) => { + pull.collect((err, inviteInfos) => { + // prettier-ignore + if (err) return cb(clarify(err, 'Failed to list invites when accepting an invite')) + // prettier-ignore + if (!inviteInfos.length) return cb(new Error("Didn't find invite for that group id")) + + // TODO: which writeKey should be picked?? + // this will essentially pick a random write key + const { id, root, readKeys } = inviteInfos[0] + pull( + pull.values(readKeys), + pull.asyncMap((readKey, cb) => { + ssb.box2.addGroupInfo(id, { key: readKey.key, root }, cb) + }), + pull.collect((err) => { + // prettier-ignore + if (err) return cb(clarify(err, 'Failed to add group info when accepting an invite')) + ssb.db.reindexEncrypted((err) => { // prettier-ignore - if (err) return cb(clarify(err, 'Failed to add group info when accepting an invite')) - ssb.db.reindexEncrypted((err) => { - // prettier-ignore - if (err) cb(clarify(err, 'Failed to reindex encrypted messages when accepting an invite')) - else cb(null, groupInfo) - }) - } - ) - }, - (err) => { - // prettier-ignore - if (err) return cb(clarify(err, 'Failed to list invites when accepting an invite')) - // prettier-ignore - if (!foundInvite) return cb(new Error("Didn't find invite for that group id")) - } - ) + if (err) cb(clarify(err, 'Failed to reindex encrypted messages when accepting an invite')) + else cb(null, inviteInfos[0]) + }) + }) + ) + }) ) } @@ -493,7 +515,6 @@ module.exports = { // look for new epochs that we're added to pull( ssb.db.query( - // TODO: does this output new stuff if we accept an invite to an old epoch and then find additions to newer epochs? where(and(isDecrypted('box2'), type('group/add-member'))), live({ old: true }), toPullStream() @@ -507,15 +528,14 @@ module.exports = { paraMap((msg, cb) => { pull( ssb.box2.listGroupIds(), + pull.filter((groupId) => + groupId === msg.value.content.recps[0] + ), + pull.take(1), pull.collect((err, groupIds) => { // prettier-ignore if (err) return cb(clarify(err, "Error getting groups we're already in when looking for new epochs")) - - if (groupIds.includes(msg.value.content.recps[0])) { - return cb(null, msg) - } else { - return cb() - } + cb(null, groupIds.length ? msg : null) }) ) }, 4), diff --git a/package.json b/package.json index 9a4708c..3f8b89d 100644 --- a/package.json +++ b/package.json @@ -40,10 +40,11 @@ "envelope-spec": "^1.1.1", "fast-deep-equal": "^3.1.3", "is-canonical-base64": "^1.1.1", + "jitdb": "^7.0.7", "lodash.chunk": "^4.2.0", - "lodash.get": "^4.4.2", "lodash.set": "^4.3.2", "private-group-spec": "^6.0.0", + "pull-defer": "^0.2.3", "pull-paramap": "^1.2.2", "pull-stream": "^3.7.0", "set.prototype.difference": "^1.0.2", diff --git a/test/exclude-members.test.js b/test/exclude-members.test.js index d92b827..ec51c3a 100644 --- a/test/exclude-members.test.js +++ b/test/exclude-members.test.js @@ -418,6 +418,120 @@ test("If you're not the excluder nor the excludee then you should still be in th ]) }) +test('Get added to an old epoch but still find newer epochs', async (t) => { + const alice = Testbot({ + keys: ssbKeys.generate(null, 'alice'), + mfSeed: Buffer.from( + '000000000000000000000000000000000000000000000000000000000000a1ce', + 'hex' + ), + }) + const bob = Testbot({ + keys: ssbKeys.generate(null, 'bob'), + mfSeed: Buffer.from( + '0000000000000000000000000000000000000000000000000000000000000b0b', + 'hex' + ), + }) + const carol = Testbot({ + keys: ssbKeys.generate(null, 'carol'), + mfSeed: Buffer.from( + '00000000000000000000000000000000000000000000000000000000000ca201', + 'hex' + ), + }) + + await alice.tribes2.start() + await bob.tribes2.start() + await carol.tribes2.start() + t.pass('tribes2 started for everyone') + + await p(alice.metafeeds.findOrCreate)() + const bobRoot = await p(bob.metafeeds.findOrCreate)() + const carolRoot = await p(carol.metafeeds.findOrCreate)() + + await replicate(alice, bob) + await replicate(alice, carol) + await replicate(bob, carol) + t.pass('everyone replicates their trees') + + const { id: groupId } = await alice.tribes2 + .create() + .catch((err) => t.error(err, 'alice failed to create group')) + + const { key: firstPostId } = await alice.tribes2 + .publish({ + type: 'test', + text: 'first post', + recps: [groupId], + }) + .catch(t.fail) + + // replicate the first group messages to bob when he can't decrypt them + await replicate(alice, bob).catch(t.error) + + await alice.tribes2 + .addMembers(groupId, [bobRoot.id, carolRoot.id]) + .then(() => t.pass('added bob and carol')) + .catch((err) => t.error(err, 'add bob and carol fail')) + + await alice.tribes2 + .excludeMembers(groupId, [carolRoot.id]) + .then(() => t.pass('alice excluded carol')) + .catch((err) => t.error(err, 'remove member fail')) + + const { key: secondPostId } = await alice.tribes2 + .publish({ + type: 'test', + text: 'second post', + recps: [groupId], + }) + .catch(t.fail) + + // only replicate bob's invite to him once we're already on the new epoch + await replicate(alice, bob).catch(t.error) + + const bobInvites = await pull( + bob.tribes2.listInvites(), + pull.collectAsPromise() + ).catch(t.fail) + t.deepEquals( + bobInvites.map((invite) => invite.id), + [groupId], + 'bob has an invite to the group' + ) + t.equals( + bobInvites[0].readKeys.length, + 2, + 'there are 2 readKeys in the invite' + ) + t.notEquals( + bobInvites[0].readKeys[0].key.toString('base64'), + bobInvites[0].readKeys[1].key.toString('base64'), + 'the two readKeys are different' + ) + + await bob.tribes2.acceptInvite(groupId).catch(t.fail) + + const bobGotFirstMsg = await p(bob.db.get)(firstPostId) + t.notEquals( + typeof bobGotFirstMsg.content, + 'string', + "bob managed to decrypt alice's first message" + ) + + const bobGotSecondMsg = await p(bob.db.get)(secondPostId) + t.notEquals( + typeof bobGotSecondMsg.content, + 'string', + "bob managed to decrypt alice's second message" + ) + + await p(alice.close)(true) + await p(bob.close)(true) + await p(carol.close)(true) +}) + test('Can exclude a person in a group with a lot of members', async (t) => { const alice = Testbot({ keys: ssbKeys.generate(null, 'alice'), diff --git a/test/invites.test.js b/test/invites.test.js index 302f7ae..652a925 100644 --- a/test/invites.test.js +++ b/test/invites.test.js @@ -45,15 +45,12 @@ test('lists correct group invite and accepting actually does something', async ( t.pass('alice and bob replicate') const invites = await pull(bob.tribes2.listInvites(), pull.collectAsPromise()) + .catch(t.error) t.equal(invites.length, 1, 'bob has 1 invite') const invite = invites[0] t.equal(invite.id, group.id, 'correct group id in invite') - t.true(invite.writeKey.key.equals(group.writeKey.key), 'correct writeKey') - t.true( - invite.readKeys[0].key.equals(group.readKeys[0].key), - 'correct readKey' - ) + t.deepEquals(invite.readKeys, group.readKeys, 'correct readKey') t.equal(invite.root, group.root, 'correct root') const msgEnc = await p(bob.db.get)(group.root).catch(t.fail) diff --git a/test/lib/epochs.test.js b/test/lib/epochs.test.js index 3d75ef2..f9ca8df 100644 --- a/test/lib/epochs.test.js +++ b/test/lib/epochs.test.js @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2022 Mix Irving +// SPDX-FileCopyrightText: 2023 Mix Irving // // SPDX-License-Identifier: CC0-1.0 diff --git a/test/list-members.test.js b/test/list-members.test.js index 8d7ba59..a3d7130 100644 --- a/test/list-members.test.js +++ b/test/list-members.test.js @@ -40,7 +40,7 @@ test('list members', async (t) => { carol.tribes2.start(), ]) - const [aliceRoot, bobRoot, carolRoot] = await Promise.all([ + const [ aliceRoot, bobRoot, carolRoot ] = await Promise.all([ p(alice.metafeeds.findOrCreate)(), p(bob.metafeeds.findOrCreate)(), p(carol.metafeeds.findOrCreate)(),