diff --git a/frontend/controller/actions/chatroom.js b/frontend/controller/actions/chatroom.js index 4a62e7dc54..8c34484cc9 100644 --- a/frontend/controller/actions/chatroom.js +++ b/frontend/controller/actions/chatroom.js @@ -4,7 +4,7 @@ import sbp from '@sbp/sbp' import { GIErrorUIRuntimeError, L } from '@common/common.js' import { has, omit } from '@model/contracts/shared/giLodash.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deserializeKey, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' @@ -63,12 +63,12 @@ export default (sbp('sbp/selectors/register', { } // Before creating the contract, put all keys into transient store - sbp('chelonia/storeSecretKeys', + await sbp('chelonia/storeSecretKeys', // $FlowFixMe[incompatible-use] - () => [cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key, transient: true })) + new Secret([cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key, transient: true }))) ) - const userCSKid = findKeyIdByName(rootState[userID], 'csk') + const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') if (!userCSKid) throw new Error('User CSK id not found') const SAK = keygen(EDWARDS25519SHA512BATCH) @@ -160,9 +160,9 @@ export default (sbp('sbp/selectors/register', { }) // After the contract has been created, store pesistent keys - sbp('chelonia/storeSecretKeys', + await sbp('chelonia/storeSecretKeys', // $FlowFixMe[incompatible-use] - () => [cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key })) + new Secret([cekOpts._rawKey, cskOpts._rawKey].map(key => ({ key }))) ) return chatroom @@ -178,11 +178,11 @@ export default (sbp('sbp/selectors/register', { const originatingContractID = state.attributes.groupContractID ? state.attributes.groupContractID : contractID // $FlowFixMe - return Promise.all(Object.keys(state.members).map((pContractID) => { - const CEKid = findKeyIdByName(rootState[pContractID], 'cek') + return Promise.all(Object.keys(state.members).map(async (pContractID) => { + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', pContractID, 'cek') if (!CEKid) { console.warn(`Unable to share rotated keys for ${originatingContractID} with ${pContractID}: Missing CEK`) - return Promise.resolve() + return } return { contractID, @@ -221,7 +221,7 @@ export default (sbp('sbp/selectors/register', { throw new Error(`Unable to send gi.actions/chatroom/join on ${params.contractID} because user ID contract ${userID} is missing`) } - const CEKid = params.encryptionKeyId || sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') + const CEKid = params.encryptionKeyId || await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') const userCSKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') return await sbp('chelonia/out/atomic', { @@ -253,7 +253,7 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/chatroom/changeDescription', L('Failed to change chat channel description.')), ...encryptedAction('gi.actions/chatroom/leave', L('Failed to leave chat channel.'), async (sendMessage, params, signingKeyId) => { const userID = params.data.memberID - const keyIds = userID && sbp('chelonia/contract/foreignKeysByContractID', params.contractID, userID) + const keyIds = userID && await sbp('chelonia/contract/foreignKeysByContractID', params.contractID, userID) if (keyIds?.length) { return await sbp('chelonia/out/atomic', { diff --git a/frontend/controller/actions/group-kv.js b/frontend/controller/actions/group-kv.js index 7a28e68c31..ac64910e42 100644 --- a/frontend/controller/actions/group-kv.js +++ b/frontend/controller/actions/group-kv.js @@ -5,8 +5,8 @@ import { KV_QUEUE } from '~/frontend/utils/events.js' export default (sbp('sbp/selectors/register', { 'gi.actions/group/kv/updateLastLoggedIn': async ({ contractID }: { contractID: string }) => { - const { ourIdentityContractId } = sbp('state/vuex/getters') - if (!ourIdentityContractId) { + const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + if (!identityContractID) { throw new Error('Unable to update lastLoggedIn without an active session') } @@ -17,13 +17,13 @@ export default (sbp('sbp/selectors/register', { return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => { const getUpdatedLastLoggedIn = async (cID, key) => { const current = (await sbp('chelonia/kv/get', cID, key))?.data || {} - return { ...current, [ourIdentityContractId]: now } + return { ...current, [identityContractID]: now } } const data = await getUpdatedLastLoggedIn(contractID, KV_KEYS.LAST_LOGGED_IN) await sbp('chelonia/kv/set', contractID, KV_KEYS.LAST_LOGGED_IN, data, { - encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek'), - signingKeyId: sbp('chelonia/contract/currentKeyIdByName', contractID, 'csk'), + encryptionKeyId: await sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek'), + signingKeyId: await sbp('chelonia/contract/currentKeyIdByName', contractID, 'csk'), onconflict: getUpdatedLastLoggedIn }) }) diff --git a/frontend/controller/actions/group.js b/frontend/controller/actions/group.js index a7c56e3453..2d102de684 100644 --- a/frontend/controller/actions/group.js +++ b/frontend/controller/actions/group.js @@ -5,7 +5,6 @@ import { CHATROOM_PRIVACY_LEVEL, INVITE_EXPIRES_IN_DAYS, INVITE_INITIAL_CREATOR, - MAX_GROUP_MEMBER_COUNT, MESSAGE_TYPES, PROFILE_STATUS, PROPOSAL_GENERIC, @@ -17,29 +16,27 @@ import { STATUS_OPEN } from '@model/contracts/shared/constants.js' import { merge, omit, randomIntFromRange } from '@model/contracts/shared/giLodash.js' -import { addTimeToDate, dateToPeriodStamp, DAYS_MILLIS } from '@model/contracts/shared/time.js' -import proposals from '@model/contracts/shared/voting/proposals.js' +import { DAYS_MILLIS, addTimeToDate, dateToPeriodStamp } from '@model/contracts/shared/time.js' +import proposals, { oneVoteToPass } from '@model/contracts/shared/voting/proposals.js' import { VOTE_FOR } from '@model/contracts/shared/voting/rules.js' import sbp from '@sbp/sbp' import { + ACCEPTED_GROUP, + JOINED_GROUP, LOGOUT, - OPEN_MODAL, - REPLACE_MODAL, - SWITCH_GROUP, - JOINED_GROUP + REPLACE_MODAL } from '@utils/events.js' import { imageUpload } from '@utils/image.js' -import { GIMessage } from '~/shared/domains/chelonia/chelonia.js' -import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' +import type { ChelKeyRequestParams } from '~/shared/domains/chelonia/chelonia.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' -import { CONTRACT_HAS_RECEIVED_KEYS, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' +import { CONTRACT_HAS_RECEIVED_KEYS } from '~/shared/domains/chelonia/events.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug -import ALLOWED_URLS from '@view-utils/allowedUrls.js' -import type { ChelKeyRequestParams } from '~/shared/domains/chelonia/chelonia.js' import type { Key } from '../../../shared/domains/chelonia/crypto.js' -import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keygen, keyId, serializeKey } from '../../../shared/domains/chelonia/crypto.js' +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' import type { GIActionParams } from './types.js' -import { encryptedAction } from './utils.js' +import { createInvite, encryptedAction } from './utils.js' export default (sbp('sbp/selectors/register', { 'gi.actions/group/create': async function ({ @@ -55,9 +52,9 @@ export default (sbp('sbp/selectors/register', { }, publishOptions }) { - let finalPicture = `${window.location.origin}/assets/images/group-avatar-default.png` + let finalPicture = `${self.location.origin}/assets/images/group-avatar-default.png` - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') const userID = rootState.loggedIn.identityContractID if (picture) { @@ -112,14 +109,14 @@ export default (sbp('sbp/selectors/register', { } // Before creating the contract, put all keys into transient store - sbp('chelonia/storeSecretKeys', - () => [CEK, CSK].map(key => ({ key, transient: true })) + await sbp('chelonia/storeSecretKeys', + new Secret([CEK, CSK].map(key => ({ key, transient: true }))) ) - const userCSKid = findKeyIdByName(rootState[userID], 'csk') + const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') if (!userCSKid) throw new Error('User CSK id not found') - const userCEKid = findKeyIdByName(rootState[userID], 'cek') + const userCEKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'cek') if (!userCEKid) throw new Error('User CEK id not found') const message = await sbp('chelonia/out/registerContract', { @@ -238,30 +235,27 @@ export default (sbp('sbp/selectors/register', { const contractID = message.contractID() // After the contract has been created, store pesistent keys - sbp('chelonia/storeSecretKeys', - () => [CEK, CSK, inviteKey].map(key => ({ key })) + await sbp('chelonia/storeSecretKeys', + new Secret([CEK, CSK, inviteKey].map(key => ({ key }))) ) - await sbp('chelonia/queueInvocation', contractID, ['gi.actions/identity/joinGroup', { - contractID: userID, - data: { - groupContractID: contractID, - inviteSecret: serializeKey(CSK, true), - creatorID: true - } - }]) + await sbp('chelonia/contract/wait', contractID).then(() => { + return sbp('gi.actions/identity/joinGroup', { + contractID: userID, + data: { + groupContractID: contractID, + inviteSecret: serializeKey(CSK, true), + creatorID: true + } + }) + }) - return message + return message.contractID() } catch (e) { console.error('gi.actions/group/create failed!', e) throw new GIErrorUIRuntimeError(L('Failed to create the group: {reportError}', LError(e))) } }, - 'gi.actions/group/createAndSwitch': async function (params: GIActionParams) { - const message = await sbp('gi.actions/group/create', params) - sbp('gi.actions/group/switch', message.contractID()) - return message - }, // The 'gi.actions/group/join' selector handles joining a group. It can be // called from a variety of places: when accepting an invite, when logging // in, and asynchronously with an event handler defined in this function. @@ -285,7 +279,7 @@ export default (sbp('sbp/selectors/register', { // side-effects could prevent us from fully leaving). await sbp('chelonia/contract/wait', [params.originatingContractID, params.contractID]) try { - const { loggedIn } = sbp('state/vuex/state') + const { loggedIn } = sbp('chelonia/rootState') if (!loggedIn) throw new Error('[gi.actions/group/join] Not logged in') const { identityContractID: userID } = loggedIn @@ -295,7 +289,7 @@ export default (sbp('sbp/selectors/register', { // ephemeral we ensure that it's not deleted until we've finished // trying to join. await sbp('chelonia/contract/retain', params.contractID, { ephemeral: true }) - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') if (!rootState.contracts[params.contractID]) { console.warn('[gi.actions/group/join] The group contract was removed after sync. If this happened during logging in, this likely means that we left the group on a different session.', { contractID: params.contractID }) return @@ -314,7 +308,7 @@ export default (sbp('sbp/selectors/register', { // automatically, even if we have a valid invitation secret and are // technically able to. However, in the previous situation we *should* // attempt to rejoin if the action was user-initiated. - const hasKeyShareBeenRespondedBy = sbp('chelonia/contract/hasKeyShareBeenRespondedBy', userID, params.contractID, params.reference) + const hasKeyShareBeenRespondedBy = await sbp('chelonia/contract/hasKeyShareBeenRespondedBy', userID, params.contractID, params.reference) const state = rootState[params.contractID] @@ -322,15 +316,17 @@ export default (sbp('sbp/selectors/register', { // perform all operations in the group? If we haven't, we are not // able to participate in the group yet and may need to send a key // request. - const hasSecretKeys = sbp('chelonia/contract/receivedKeysToPerformOperation', userID, state, '*') + const hasSecretKeys = await sbp('chelonia/contract/receivedKeysToPerformOperation', userID, state, '*') // Do we need to send a key request? // If we don't have the group contract in our state and // params.originatingContractID is set, it means that we're joining // through an invite link, and we must send a key request to complete // the joining process. - const sendKeyRequest = (!hasKeyShareBeenRespondedBy && !hasSecretKeys && params.originatingContractID) - const pendingKeyShares = sbp('chelonia/contract/waitingForKeyShareTo', state, userID, params.reference) + const sendKeyRequest = (!hasKeyShareBeenRespondedBy && !hasSecretKeys && !!params.originatingContractID) + console.error('@@@sendKeyRequest= ', sendKeyRequest, [!hasKeyShareBeenRespondedBy, !hasSecretKeys, !!params.originatingContractID]) + const pendingKeyShares = await sbp('chelonia/contract/waitingForKeyShareTo', state, userID, params.reference) + console.error('@@@JOIN', { sendKeyRequest, pendingKeyShares, cID: params.contractID }) // If we are expecting to receive keys, set up an event listener // We are expecting to receive keys if: @@ -350,6 +346,7 @@ export default (sbp('sbp/selectors/register', { // A different path should be taken, since te event handler // should be called after the key request has been answered // and processed + console.error('@@@@JOIN HAS RECEIVED KEYS') sbp('gi.actions/group/join', params).catch((e) => { console.error('[gi.actions/group/join] Error during join (inside CONTRACT_HAS_RECEIVED_KEYS event handler)', e) }) @@ -385,7 +382,7 @@ export default (sbp('sbp/selectors/register', { // (originating) contract. await sbp('chelonia/out/keyRequest', { ...omit(params, ['options']), - innerEncryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek'), + innerEncryptionKeyId: await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek'), permissions: [GIMessage.OP_ACTION_ENCRYPTED], allowedActions: ['gi.contracts/identity/joinDirectMessage'], reference: params.reference, @@ -416,10 +413,10 @@ export default (sbp('sbp/selectors/register', { // synchronously, before any await calls. // If reading after an asynchronous operation, we might get inconsistent // values, as new operations could have been received on the contract - const CEKid = sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') - const PEKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'pek') - const CSKid = sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'csk') - const userCSKid = sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'cek') + const PEKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'pek') + const CSKid = await sbp('chelonia/contract/currentKeyIdByName', params.contractID, 'csk') + const userCSKid = await sbp('chelonia/contract/currentKeyIdByName', userID, 'csk') const userCSKdata = rootState[userID]._vm.authorizedKeys[userCSKid].data try { @@ -456,6 +453,9 @@ export default (sbp('sbp/selectors/register', { await sbp('chelonia/contract/wait', params.contractID) await sbp('gi.actions/group/inviteAccept', { ...omit(params, ['options', 'action', 'hooks', 'encryptionKeyId', 'signingKeyId']), + data: { + reference: rootState[userID].groups[params.contractID].hash + }, hooks: { prepublish: params.hooks?.prepublish, postpublish: null @@ -471,7 +471,7 @@ export default (sbp('sbp/selectors/register', { } } - sbp('okTurtles.events/emit', JOINED_GROUP, { contractID: params.contractID }) + sbp('okTurtles.events/emit', JOINED_GROUP, { identityContractID: userID, contractID: params.contractID }) // We don't have the secret keys and we're not waiting for OP_KEY_SHARE // This means that we've been removed from the group } else if (!hasSecretKeys && !pendingKeyShares) { @@ -504,15 +504,11 @@ export default (sbp('sbp/selectors/register', { // multiple calls to join for the same contract can result in conflicting with // each other 'gi.actions/group/join': function (params: $Exact) { + console.error('@@@@Called /join', new Error().stack) return sbp('okTurtles.eventQueue/queueEvent', `JOIN_GROUP-${params.contractID}`, ['gi.actions/group/_private/join', params]) }, - 'gi.actions/group/joinAndSwitch': async function (params: $Exact) { - await sbp('gi.actions/group/join', params) - // after joining, we can set the current group - return sbp('gi.actions/group/switch', params.contractID) - }, 'gi.actions/group/joinWithInviteSecret': async function (groupId: string, secret: string) { - const identityContractID = sbp('state/vuex/state').loggedIn.identityContractID + const identityContractID = sbp('chelonia/rootState').loggedIn.identityContractID // This action (`joinWithInviteSecret`) can get invoked while there are // events being processed in the group or identity contracts. This can cause @@ -542,27 +538,21 @@ export default (sbp('sbp/selectors/register', { groupContractID: groupId, inviteSecret: secret } - }).then(() => { - return sbp('gi.actions/group/switch', groupId) }) } finally { await sbp('chelonia/contract/release', groupId, { ephemeral: true }) } }, - 'gi.actions/group/switch': function (groupId) { - sbp('state/vuex/commit', 'setCurrentGroupId', groupId) - sbp('okTurtles.events/emit', SWITCH_GROUP) - }, 'gi.actions/group/shareNewKeys': (contractID: string, newKeys) => { - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') const state = rootState[contractID] // $FlowFixMe return Promise.all( Object.entries(state.profiles) .filter(([_, p]) => (p: any).status === PROFILE_STATUS.ACTIVE) - .map(([pContractID]) => { - const CEKid = sbp('chelonia/contract/currentKeyIdByName', rootState[pContractID], 'cek') + .map(async ([pContractID]) => { + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', rootState[pContractID], 'cek') if (!CEKid) { console.warn(`Unable to share rotated keys for ${contractID} with ${pContractID}: Missing CEK`) return Promise.resolve() @@ -583,7 +573,7 @@ export default (sbp('sbp/selectors/register', { })) }, ...encryptedAction('gi.actions/group/addChatRoom', L('Failed to add chat channel'), async function (sendMessage, params) { - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') const contractState = rootState[params.contractID] const userID = rootState.loggedIn.identityContractID for (const contractId in contractState.chatRooms) { @@ -592,14 +582,14 @@ export default (sbp('sbp/selectors/register', { } } - const cskId = sbp('chelonia/contract/currentKeyIdByName', contractState, 'csk') + const cskId = await sbp('chelonia/contract/currentKeyIdByName', contractState, 'csk') const csk = { id: cskId, foreignKey: `sp:${encodeURIComponent(params.contractID)}?keyName=${encodeURIComponent('csk')}`, data: contractState._vm.authorizedKeys[cskId].data } - const cekId = sbp('chelonia/contract/currentKeyIdByName', contractState, 'cek') + const cekId = await sbp('chelonia/contract/currentKeyIdByName', contractState, 'cek') const cek = { id: cekId, foreignKey: `sp:${encodeURIComponent(params.contractID)}?keyName=${encodeURIComponent('cek')}`, @@ -660,15 +650,10 @@ export default (sbp('sbp/selectors/register', { return message }), ...encryptedAction('gi.actions/group/joinChatRoom', L('Failed to join chat channel.'), async function (sendMessage, params) { - const rootState = sbp('state/vuex/state') - const rootGetters = sbp('state/vuex/getters') + const rootState = sbp('chelonia/rootState') const me = rootState.loggedIn.identityContractID const memberID = params.data.memberID || me - if (!rootGetters.isJoinedChatRoom(params.data.chatRoomID) && memberID !== me) { - throw new GIErrorUIRuntimeError(L('Only channel members can invite others to join.')) - } - // If we are inviting someone else to join, we need to share the chatroom's keys // with them so that they are able to read messages and participate if (memberID !== me && rootState[params.data.chatRoomID].attributes.privacyLevel === CHATROOM_PRIVACY_LEVEL.PRIVATE) { @@ -699,27 +684,11 @@ export default (sbp('sbp/selectors/register', { ...omit(params, ['options', 'data', 'hooks']), data: { chatRoomID }, hooks: { - // joinChatRoom sideEffect will trigger a call to 'gi.actions/chatroom/join', we want - // to wait for that action to be received and processed, and then switch the UI to the - // new chatroom. We do this here instead of in the sideEffect for chatroom/join to - // avoid causing the UI to change in other open tabs/windows, as per bug: - // https://github.com/okTurtles/group-income/issues/1960 - onprocessed: (msg) => { - const fnEventHandled = (cID, message) => { - if (cID === chatRoomID) { - if (sbp('state/vuex/getters').isJoinedChatRoom(chatRoomID)) { - sbp('state/vuex/commit', 'setCurrentChatRoomId', { chatRoomID, groupID: msg.contractID() }) - sbp('okTurtles.events/off', EVENT_HANDLED, fnEventHandled) - } - } - } - sbp('okTurtles.events/on', EVENT_HANDLED, fnEventHandled) - }, postpublish: params.hooks?.postpublish } }) - return message + return chatRoomID }, ...encryptedAction('gi.actions/group/renameChatRoom', L('Failed to rename chat channel.'), async function (sendMessage, params) { await sbp('gi.actions/chatroom/rename', { @@ -801,7 +770,7 @@ export default (sbp('sbp/selectors/register', { // see: https://stackoverflow.com/a/41329247/1781435 const memberID = msgMeta && msgMeta.innerSigningContractID const groupID = message.contractID() - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') const contractState = rootState[groupID] if (memberID && rootState.contracts[groupID]?.type === 'gi.contracts/group' && contractState?.profiles?.[memberID]?.status === PROFILE_STATUS.ACTIVE) { const rootGetters = sbp('state/vuex/getters') @@ -873,7 +842,7 @@ export default (sbp('sbp/selectors/register', { } }) - const rootState = sbp('state/vuex/state') + const rootState = sbp('chelonia/rootState') const { generalChatRoomId } = rootState[params.contractID] for (const proposal of proposals) { @@ -916,45 +885,21 @@ export default (sbp('sbp/selectors/register', { sbp('okTurtles.events/emit', REPLACE_MODAL, 'IncomeDetails') } }, - 'gi.actions/group/checkGroupSizeAndProposeMember': async function () { - // if current size of the group is >= 150, display a warning prompt first before presenting the user with - // 'AddMembers' proposal modal. - - const enforceDunbar = true // Context for this hard-coded boolean variable: https://github.com/okTurtles/group-income/pull/1648#discussion_r1230389924 - const { groupMembersCount, currentGroupState } = sbp('state/vuex/getters') - const memberInvitesCount = Object.values(currentGroupState.invites || {}).filter((invite: any) => invite.creatorID !== INVITE_INITIAL_CREATOR).length - const isGroupSizeLarge = (groupMembersCount + memberInvitesCount) >= MAX_GROUP_MEMBER_COUNT - - if (isGroupSizeLarge) { - const translationArgs = { - a_: ``, - _a: '' - } - const promptConfig = enforceDunbar - ? { - heading: 'Large group size', - question: L("Group sizes are limited to {a_}Dunbar's Number{_a} to prevent fraud.", translationArgs), - primaryButton: L('OK') - } - : { - heading: 'Large group size', - question: L("Groups over 150 members are at significant risk for fraud, {a_}because it is difficult to verify everyone's identity.{_a} Are you sure that you want to add more members?", translationArgs), - primaryButton: L('Yes'), - secondaryButton: L('Cancel') - } - - const primaryButtonSelected = await sbp('gi.ui/prompt', promptConfig) - if (!enforceDunbar && primaryButtonSelected) { - sbp('okTurtles.events/emit', REPLACE_MODAL, 'AddMembers') - } else return false - } else { - sbp('okTurtles.events/emit', OPEN_MODAL, 'AddMembers') - } - }, ...encryptedAction('gi.actions/group/leaveChatRoom', L('Failed to leave chat channel.')), ...encryptedAction('gi.actions/group/deleteChatRoom', L('Failed to delete chat channel.')), ...encryptedAction('gi.actions/group/invite', L('Failed to create invite.')), - ...encryptedAction('gi.actions/group/inviteAccept', L('Failed to accept invite.')), + ...encryptedAction('gi.actions/group/inviteAccept', L('Failed to accept invite.'), function (sendMessage, params) { + return sendMessage({ + ...params, + hooks: { + ...params?.hooks, + postpublish: (...args) => { + sbp('okTurtles.events/emit', ACCEPTED_GROUP, { contractID: params.contractID }) + params.hooks?.postpublish?.(...args) + } + } + }) + }), ...encryptedAction('gi.actions/group/inviteRevoke', L('Failed to revoke invite.'), async function (sendMessage, params, signingKeyId) { await sbp('chelonia/out/keyDel', { contractID: params.contractID, @@ -970,7 +915,36 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/group/sendPaymentThankYou', L('Failed to send a payment thank you note.')), ...encryptedAction('gi.actions/group/groupProfileUpdate', L('Failed to update group profile.')), ...encryptedAction('gi.actions/group/proposal', L('Failed to create proposal.')), - ...encryptedAction('gi.actions/group/proposalVote', L('Failed to vote on proposal.')), + ...encryptedAction('gi.actions/group/proposalVote', L('Failed to vote on proposal.'), async (sendMessage, params) => { + const state = await sbp('chelonia/contract/state', params.contractID) + const data = params.data + const proposalHash = data.proposalHash + const proposal = state.proposals[proposalHash] + const type = proposal.data.proposalType + let passPayload + + if (data.vote === VOTE_FOR) { + passPayload = {} + if (oneVoteToPass(state, proposalHash)) { + if (type === PROPOSAL_INVITE_MEMBER) { + passPayload = await createInvite({ + contractID: params.contractID, + invitee: proposal.data.proposalData.memberName, + creatorID: proposal.creatorID, + expires: state.settings.inviteExpiryProposal + }) + } + } + } + + return sendMessage({ + ...params, + data: { + ...data, + passPayload + } + }) + }), ...encryptedAction('gi.actions/group/proposalCancel', L('Failed to cancel proposal.')), ...encryptedAction('gi.actions/group/updateSettings', L('Failed to update group settings.')), ...encryptedAction('gi.actions/group/updateAllVotingRules', (params, e) => L('Failed to update voting rules. {codeError}', { codeError: e.message })), diff --git a/frontend/controller/actions/identity-kv.js b/frontend/controller/actions/identity-kv.js index 51c566f6eb..6997f77266 100644 --- a/frontend/controller/actions/identity-kv.js +++ b/frontend/controller/actions/identity-kv.js @@ -14,26 +14,27 @@ export default (sbp('sbp/selectors/register', { }, // Unread Messages 'gi.actions/identity/kv/fetchChatRoomUnreadMessages': async () => { - const { ourIdentityContractId } = sbp('state/vuex/getters') - if (!ourIdentityContractId) { + const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + if (!identityContractID) { throw new Error('Unable to fetch chatroom unreadMessages without an active session') } - return (await sbp('chelonia/kv/get', ourIdentityContractId, KV_KEYS.UNREAD_MESSAGES))?.data || {} + return (await sbp('chelonia/kv/get', identityContractID, KV_KEYS.UNREAD_MESSAGES))?.data || {} }, 'gi.actions/identity/kv/saveChatRoomUnreadMessages': ({ data, onconflict }: { data: Object, onconflict?: Function }) => { - const { ourIdentityContractId } = sbp('state/vuex/getters') - if (!ourIdentityContractId) { + const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + if (!identityContractID) { throw new Error('Unable to update chatroom unreadMessages without an active session') } - return sbp('chelonia/kv/set', ourIdentityContractId, KV_KEYS.UNREAD_MESSAGES, data, { - encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', ourIdentityContractId, 'cek'), - signingKeyId: sbp('chelonia/contract/currentKeyIdByName', ourIdentityContractId, 'csk'), + return sbp('chelonia/kv/set', identityContractID, KV_KEYS.UNREAD_MESSAGES, data, { + encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'cek'), + signingKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk'), onconflict }) }, 'gi.actions/identity/kv/loadChatRoomUnreadMessages': () => { return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => { const currentChatRoomUnreadMessages = await sbp('gi.actions/identity/kv/fetchChatRoomUnreadMessages') + // TODO: Can't use state/vuex/commit sbp('state/vuex/commit', 'setUnreadMessages', currentChatRoomUnreadMessages) }) }, @@ -152,26 +153,27 @@ export default (sbp('sbp/selectors/register', { }, // Preferences 'gi.actions/identity/kv/fetchPreferences': async () => { - const { ourIdentityContractId } = sbp('state/vuex/getters') - if (!ourIdentityContractId) { + const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + if (!identityContractID) { throw new Error('Unable to fetch preferences without an active session') } - return (await sbp('chelonia/kv/get', ourIdentityContractId, KV_KEYS.PREFERENCES))?.data || {} + return (await sbp('chelonia/kv/get', identityContractID, KV_KEYS.PREFERENCES))?.data || {} }, 'gi.actions/identity/kv/savePreferences': ({ data, onconflict }: { data: Object, onconflict?: Function }) => { - const { ourIdentityContractId } = sbp('state/vuex/getters') - if (!ourIdentityContractId) { + const identityContractID = sbp('chelonia/rootState').loggedIn?.identityContractID + if (!identityContractID) { throw new Error('Unable to update preferences without an active session') } - return sbp('chelonia/kv/set', ourIdentityContractId, KV_KEYS.PREFERENCES, data, { - encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', ourIdentityContractId, 'cek'), - signingKeyId: sbp('chelonia/contract/currentKeyIdByName', ourIdentityContractId, 'csk'), + return sbp('chelonia/kv/set', identityContractID, KV_KEYS.PREFERENCES, data, { + encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'cek'), + signingKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk'), onconflict }) }, 'gi.actions/identity/kv/loadPreferences': () => { return sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, async () => { const preferences = await sbp('gi.actions/identity/kv/fetchPreferences') + // TODO: Can't use state/vuex/commit sbp('state/vuex/commit', 'setPreferences', preferences) }) }, diff --git a/frontend/controller/actions/identity.js b/frontend/controller/actions/identity.js index e08bdfe68d..5cbcf0e6e8 100644 --- a/frontend/controller/actions/identity.js +++ b/frontend/controller/actions/identity.js @@ -1,76 +1,43 @@ 'use strict' -import { GIErrorUIRuntimeError, L, LError, LTags } from '@common/common.js' +import { GIErrorUIRuntimeError, L, LError } from '@common/common.js' import { CHATROOM_PRIVACY_LEVEL, CHATROOM_TYPES, PROFILE_STATUS } from '@model/contracts/shared/constants.js' -import { has, omit } from '@model/contracts/shared/giLodash.js' +import { has, omit, cloneDeep } from '@model/contracts/shared/giLodash.js' import sbp from '@sbp/sbp' import { imageUpload, objectURLtoBlob } from '@utils/image.js' import { SETTING_CURRENT_USER } from '~/frontend/model/database.js' -import { LOGIN, LOGIN_ERROR, LOGOUT, KV_QUEUE } from '~/frontend/utils/events.js' +import { LOGIN, LOGOUT, KV_QUEUE } from '~/frontend/utils/events.js' import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' -import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' -import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' import { encryptedOutgoingData, encryptedOutgoingDataWithRawKey } from '~/shared/domains/chelonia/encryptedData.js' // Using relative path to crypto.js instead of ~-path to workaround some esbuild bug -import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js' +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, serializeKey, keyId, keygen, deserializeKey } from '../../../shared/domains/chelonia/crypto.js' import type { Key } from '../../../shared/domains/chelonia/crypto.js' -import { handleFetchResult } from '../utils/misc.js' import { encryptedAction } from './utils.js' export default (sbp('sbp/selectors/register', { - 'gi.actions/identity/retrieveSalt': async (username: string, passwordFn: () => string) => { - const r = randomNonce() - const b = hash(r) - const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/auth_hash?b=${encodeURIComponent(b)}`) - .then(handleFetchResult('json')) - - const { authSalt, s, sig } = authHash - - const h = await hashPassword(passwordFn(), authSalt) - - const [c, hc] = computeCAndHc(r, s, h) - - const contractHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/contract_hash?${(new URLSearchParams({ - 'r': r, - 's': s, - 'sig': sig, - 'hc': Buffer.from(hc).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') - })).toString()}`).then(handleFetchResult('text')) - - return decryptContractSalt(c, contractHash) - }, 'gi.actions/identity/create': async function ({ - data: { username, email, passwordFn, picture }, - publishOptions + IPK, + IEK, + publishOptions, + username, + email, + picture, + r, + s, + sig, + Eh }) { - const password = passwordFn() - let finalPicture = `${window.location.origin}/assets/images/user-avatar-default.png` - - // proceed with creation - const keyPair = boxKeyPair() - const r = Buffer.from(keyPair.publicKey).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') - const b = hash(r) - // TODO: use the contractID instead, and move this code down below the registration - const registrationRes = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, { - method: 'POST', - headers: { - 'content-type': 'application/x-www-form-urlencoded' - }, - body: `b=${encodeURIComponent(b)}` - }) - .then(handleFetchResult('json')) + let finalPicture = `${self.location.origin}/assets/images/user-avatar-default.png` - const { p, s, sig } = registrationRes - - const [contractSalt, Eh] = await buildRegisterSaltRequest(p, keyPair.secretKey, password) + IPK = typeof IPK === 'string' ? deserializeKey(IPK) : IPK + IEK = typeof IEK === 'string' ? deserializeKey(IEK) : IEK // Create the necessary keys to initialise the contract - const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, contractSalt) - const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt) const CSK = keygen(EDWARDS25519SHA512BATCH) const CEK = keygen(CURVE25519XSALSA20POLY1305) const PEK = keygen(CURVE25519XSALSA20POLY1305) @@ -99,8 +66,8 @@ export default (sbp('sbp/selectors/register', { const SAKs = encryptedOutgoingDataWithRawKey(IEK, serializeKey(SAK, true)) // Before creating the contract, put all keys into transient store - sbp('chelonia/storeSecretKeys', - () => [IPK, IEK, CEK, CSK, PEK, SAK].map(key => ({ key, transient: true })) + await sbp('chelonia/storeSecretKeys', + new Secret([IPK, IEK, CEK, CSK, PEK, SAK].map(key => ({ key, transient: true }))) ) let userID @@ -209,7 +176,7 @@ export default (sbp('sbp/selectors/register', { const res = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, { method: 'POST', headers: { - 'authorization': sbp('chelonia/shelterAuthorizationHeader', message.contractID()), + 'authorization': await sbp('chelonia/shelterAuthorizationHeader', message.contractID()), 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ @@ -230,7 +197,7 @@ export default (sbp('sbp/selectors/register', { finalPicture = await imageUpload(picture, { billableContractID: userID }) } catch (e) { console.error('actions/identity.js picture upload error:', e) - throw new GIErrorUIRuntimeError(L('Failed to upload the profile picture. {codeError}', { codeError: e.message })) + throw new GIErrorUIRuntimeError(L('Failed to upload the profile picture. {codeError}', { codeError: e.message }), { cause: e }) } } } @@ -245,83 +212,52 @@ export default (sbp('sbp/selectors/register', { }) // After the contract has been created, store pesistent keys - sbp('chelonia/storeSecretKeys', - () => [CEK, CSK, PEK].map(key => ({ key })) + await sbp('chelonia/storeSecretKeys', + new Secret([CEK, CSK, PEK].map(key => ({ key }))) ) // And remove transient keys, which require a user password sbp('chelonia/clearTransientSecretKeys', [IEKid, IPKid]) } catch (e) { console.error('gi.actions/identity/create failed!', e) - throw new GIErrorUIRuntimeError(L('Failed to create user identity: {reportError}', LError(e))) + throw new GIErrorUIRuntimeError(L('Failed to create user identity: {reportError}', LError(e)), { cause: e }) + } finally { + // And remove transient keys, which require a user password + await sbp('chelonia/clearTransientSecretKeys', [IEKid, IPKid]) } return userID }, - 'gi.actions/identity/signup': async function ({ username, email, passwordFn }, publishOptions) { - try { - const randomAvatar = sbp('gi.utils/avatar/create') - const userID = await sbp('gi.actions/identity/create', { - data: { - username, - email, - passwordFn, - picture: randomAvatar - }, - publishOptions - }) - return userID - } catch (e) { - await sbp('gi.actions/identity/logout') // TODO: should this be here? - console.error('gi.actions/identity/signup failed!', e) - const message = LError(e) - if (e.name === 'GIErrorUIRuntimeError') { - // 'gi.actions/identity/create' also sets reportError - message.reportError = e.message - } - throw new GIErrorUIRuntimeError(L('Failed to signup: {reportError}', message)) - } - }, - 'gi.actions/identity/login': async function ({ username, passwordFn, identityContractID }: { - username: ?string, passwordFn: ?() => string, identityContractID: ?string - }) { - if (username) { - identityContractID = await sbp('namespace/lookup', username) - } + 'gi.actions/identity/login': async function ({ identityContractID, encryptionParams, cheloniaState, state, transientSecretKeys }) { + transientSecretKeys = transientSecretKeys.map(k => ({ key: deserializeKey(k.valueOf()), transient: true })) - if (!identityContractID) { - throw new GIErrorUIRuntimeError(L('Incorrect username or password')) - } + console.error('@@@@LOGIN', { identityContractID }) + await sbp('chelonia/reset', { ...cheloniaState, loggedIn: { identityContractID } }) + await sbp('chelonia/storeSecretKeys', new Secret(transientSecretKeys)) try { - sbp('appLogs/startCapture', identityContractID) - - const password = passwordFn?.() - const transientSecretKeys = [] - - if (password) { - try { - const salt = await sbp('gi.actions/identity/retrieveSalt', username, passwordFn) - const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt) - transientSecretKeys.push({ key: IEK, transient: true }) - } catch (e) { - console.error('caught error calling retrieveSalt:', e) - throw new GIErrorUIRuntimeError(L('Incorrect username or password')) - } + if (!state) { + // Make sure we don't unsubscribe from our own identity contract + // Note that this should be done _after_ calling + // `chelonia/storeSecretKeys`: If the following line results in + // syncing the identity contract and fetching events, the secret keys + // for processing them will not be available otherwise. + await sbp('chelonia/contract/retain', identityContractID) + } else { + // If there is a state, we've already retained the identity contract + // but might need to fetch the latest events + await sbp('chelonia/contract/sync', identityContractID, { force: true }) } + } catch (e) { + console.error('Error during login contract sync', e) + throw new GIErrorUIRuntimeError(L('Error during login contract sync'), { cause: e }) + } - const { encryptionParams, value: state } = await sbp('gi.db/settings/loadEncrypted', identityContractID, password && ((stateEncryptionKeyId, salt) => { - return deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt + stateEncryptionKeyId) - })) - + try { const contractIDs = Object.create(null) - // login can be called when no settings are saved (e.g. from Signup.vue) - if (state) { - // The retrieved local data might need to be completed in case it was originally saved - // under an older version of the app where fewer/other Vuex modules were implemented. - sbp('state/vuex/postUpgradeVerification', state) - sbp('state/vuex/replace', state) - sbp('chelonia/pubsub/update') // resubscribe to contracts since we replaced the state + + if (cheloniaState) { + await sbp('chelonia/pubsub/update') // resubscribe to contracts since we replaced the state // $FlowFixMe[incompatible-use] - Object.entries(state.contracts).forEach(([id, { type }]) => { + Object.entries(cheloniaState.contracts).forEach(([id, { type }]) => { if (!contractIDs[type]) { contractIDs[type] = [] } @@ -330,18 +266,7 @@ export default (sbp('sbp/selectors/register', { } await sbp('gi.db/settings/save', SETTING_CURRENT_USER, identityContractID) - - const loginAttributes = { identityContractID, encryptionParams, username } - - // If username was not provided, retrieve it from the state - if (!loginAttributes.username) { - loginAttributes.username = Object.entries(state.namespaceLookups) - .find(([k, v]) => v === identityContractID) - ?.[0] - } - - sbp('state/vuex/commit', 'login', loginAttributes) - await sbp('chelonia/storeSecretKeys', () => transientSecretKeys) + sbp('okTurtles.events/emit', LOGIN, { identityContractID, encryptionParams, state }) // We need to sync contracts in this order to ensure that we have all the // corresponding secret keys. Group chatrooms use group keys but there's @@ -359,41 +284,13 @@ export default (sbp('sbp/selectors/register', { return index === -1 ? contractSyncPriorityList.length : index } - // loading the website instead of stalling out. - try { - if (!state) { - // Make sure we don't unsubscribe from our own identity contract - // Note that this should be done _after_ calling - // `chelonia/storeSecretKeys`: If the following line results in - // syncing the identity contract and fetching events, the secret keys - // for processing them will not be available otherwise. - await sbp('chelonia/contract/retain', identityContractID) - } else { - // If there is a state, we've already retained the identity contract - // but might need to fetch the latest events - await sbp('chelonia/contract/sync', identityContractID, { force: true }) - } - } catch (err) { - sbp('okTurtles.events/emit', LOGIN_ERROR, { username, identityContractID, error: err }) - const errMessage = err?.message || String(err) - console.error('Error during login contract sync', errMessage) - - const promptOptions = { - heading: L('Login error'), - question: L('Do you want to log out? {br_}Error details: {err}.', { err: err.message, ...LTags() }), - primaryButton: L('No'), - secondaryButton: L('Yes') - } - - const result = await sbp('gi.ui/prompt', promptOptions) - if (!result) { - return sbp('gi.actions/identity/logout') - } else { - throw err - } - } - await sbp('gi.actions/identity/kv/load') + // NOTE: update chatRoomUnreadMessages to the latest one we do this here + // just after the identity contract is synced because + // while syncing the chatroom contract it could be necessary to update chatRoomUnreadMessages + await sbp('gi.actions/identity/kv/loadChatRoomUnreadMessages') + // NOTE: load users preferences config which is saved in KV store + await sbp('gi.actions/identity/kv/loadPreferences') try { // $FlowFixMe[incompatible-call] @@ -405,17 +302,15 @@ export default (sbp('sbp/selectors/register', { })) } catch (err) { console.error('Error during contract sync upon login (syncing all contractIDs)', err) - - const humanErr = L('Sync error during login: {msg}', { msg: err?.message || 'unknown error' }) - throw new GIErrorUIRuntimeError(humanErr, { cause: err }) + throw err } try { // The state above might be null, so we re-grab it - const state = sbp('state/vuex/state') + const cheloniaState = sbp('chelonia/rootState') // The updated list of groups - const groupIds = Object.keys(state[identityContractID].groups) + const groupIds = Object.keys(cheloniaState[identityContractID].groups) // contract sync might've triggered an async call to /remove, so // wait before proceeding @@ -424,28 +319,29 @@ export default (sbp('sbp/selectors/register', { // Call 'gi.actions/group/join' on all groups which may need re-joining await Promise.allSettled( - groupIds.map(groupId => ( + groupIds.map(async groupId => ( // (1) Check whether the contract exists (may have been removed // after sync) - has(state.contracts, groupId) && - has(state[identityContractID].groups, groupId) && + has(cheloniaState.contracts, groupId) && + has(cheloniaState[identityContractID].groups, groupId) && // (2) Check whether the join process is still incomplete // This needs to be re-checked because it may have changed after // sync // // We only check for groups where we don't have a profile, as // // re-joining is handled by the group contract itself. // !state[groupId]?.profiles?.[identityContractID] && // ?.status !== PROFILE_STATUS. - state[groupId]?.profiles?.[identityContractID]?.status !== PROFILE_STATUS.ACTIVE && + cheloniaState[groupId]?.profiles?.[identityContractID]?.status !== PROFILE_STATUS.ACTIVE && + (console.error('IDENTITY ACTION WILL CALL /join', { groupId }) || true) && // (3) Call join sbp('gi.actions/group/join', { originatingContractID: identityContractID, originatingContractName: 'gi.contracts/identity', contractID: groupId, contractName: 'gi.contracts/group', - reference: state[identityContractID].groups[groupId].hash, - signingKeyId: state[identityContractID].groups[groupId].inviteSecretId, - innerSigningKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk'), - encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'cek') + reference: cheloniaState[identityContractID].groups[groupId].hash, + signingKeyId: cheloniaState[identityContractID].groups[groupId].inviteSecretId, + innerSigningKeyId: await sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'csk'), + encryptionKeyId: await sbp('chelonia/contract/currentKeyIdByName', identityContractID, 'cek') }).catch((e) => { console.error(`Error during gi.actions/group/join for ${groupId} at login`, e) const humanErr = L('Join group error during login: {msg}', { msg: e?.message || 'unknown error' }) @@ -455,45 +351,29 @@ export default (sbp('sbp/selectors/register', { ) // update the 'lastLoggedIn' field in user's group profiles - Object.keys(state[identityContractID].groups) + Object.keys(cheloniaState[identityContractID].groups) .forEach(cId => { // We send this action only for groups we have fully joined (i.e., // accepted an invite and added our profile) - if (state[cId]?.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE) { + if (cheloniaState[cId]?.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE) { sbp('gi.actions/group/kv/updateLastLoggedIn', { contractID: cId }).catch((e) => console.error('Error sending updateLastLoggedIn', e)) } }) - - // NOTE: users could notice that they leave the group by someone else when they log in - if (!state.currentGroupId) { - const gId = Object.keys(state.contracts).find(cID => has(state[identityContractID].groups, cID)) - - if (gId) { - sbp('gi.actions/group/switch', gId) - } - } } catch (e) { console.error('[gi.actions/identity/login] Error re-joining groups after login', e) - throw new GIErrorUIRuntimeError(e?.message || L('unkown error')) - } finally { - sbp('okTurtles.events/emit', LOGIN, { username, identityContractID }) + throw e } return identityContractID } catch (e) { + // TODO: Remove transient secret keys console.error('gi.actions/identity/login failed!', e) - const humanErr = L('{reportError}', LError(e, true)) - const promptOptions = { - heading: L('Failed to login'), - question: L('Error details:{br_}{err}', { err: humanErr, ...LTags() }), - primaryButton: L('Close') - } - - await sbp('gi.ui/prompt', promptOptions) + const humanErr = L('Failed to login: {reportError}', LError(e)) await sbp('gi.actions/identity/logout') .catch((e) => { console.error('[gi.actions/identity/login] Error calling logout (after failure to login)', e) }) + throw new GIErrorUIRuntimeError(humanErr, { cause: e }) } }, 'gi.actions/identity/signupAndLogin': async function ({ username, email, passwordFn }) { @@ -502,11 +382,12 @@ export default (sbp('sbp/selectors/register', { return contractIDs }, 'gi.actions/identity/logout': async function () { + let cheloniaState try { - const state = sbp('state/vuex/state') console.info('logging out, waiting for any events to finish...') // wait for any pending operations to finish before calling state/vuex/save // This includes, in order: + // 0. Pending login events // 1. Actions to be sent (in the `encrypted-action` queue) // 2. (In reset) Actions that haven't been published yet (the // `publish:${contractID}` queues) @@ -514,9 +395,11 @@ export default (sbp('sbp/selectors/register', { // queues), including their side-effects (the `${contractID}` queues) // 4. (In reset handler) Outgoing actions from side-effects (again, in // the `encrypted-action` queue) + cheloniaState = await sbp('chelonia/rootState') + await sbp('okTurtles.eventQueue/queueEvent', `login:${cheloniaState.loggedIn?.identityContractID ?? '(null)'}`, () => {}) await sbp('okTurtles.eventQueue/queueEvent', 'encrypted-action', () => {}) // reset will wait until we have processed any remaining actions - await sbp('chelonia/reset', async () => { + cheloniaState = await sbp('chelonia/reset', async () => { // some of the actions that reset waited for might have side-effects // that send actions // we wait for those as well (the duplication in this case is @@ -531,34 +414,36 @@ export default (sbp('sbp/selectors/register', { await sbp('okTurtles.eventQueue/queueEvent', KV_QUEUE, () => {}) // See comment below for 'gi.db/settings/delete' - await sbp('state/vuex/save') - - // If there is a state encryption key in the app settings, remove it - const encryptionParams = state.loggedIn?.encryptionParams - if (encryptionParams) { - await sbp('gi.db/settings/deleteStateEncryptionKey', encryptionParams) - } + const cheloniaState = await sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', async () => { + const cheloniaState = cloneDeep(sbp('chelonia/rootState')) + await sbp('gi.db/settings/delete', 'CHELONIA_STATE') + return cheloniaState + }) await sbp('gi.db/settings/save', SETTING_CURRENT_USER, null) - }).then(() => { + + return cheloniaState + }).then((cheloniaState) => { console.info('successfully logged out') + + return cheloniaState }) } catch (e) { console.error(`${e.name} during logout: ${e.message}`, e) } // Clear the file cache when logging out to preserve privacy sbp('gi.db/filesCache/clear').catch((e) => { console.error('Error clearing file cache', e) }) - sbp('state/vuex/reset') sbp('okTurtles.events/emit', LOGOUT) - sbp('appLogs/pauseCapture', { wipeOut: true }) // clear stored logs to prevent someone else accessing sensitve data + console.error('@@@@@@LOGOUT@@@@@@', sbp('chelonia/rootState')) + return cheloniaState }, 'gi.actions/identity/addJoinDirectMessageKey': async (contractID, foreignContractID, keyName) => { - const keyId = sbp('chelonia/contract/currentKeyIdByName', foreignContractID, keyName) - const CEKid = sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek') + const keyId = await sbp('chelonia/contract/currentKeyIdByName', foreignContractID, keyName) + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', contractID, 'cek') const rootState = sbp('state/vuex/state') const foreignContractState = rootState[foreignContractID] - const existingForeignKeys = sbp('chelonia/contract/foreignKeysByContractID', contractID, foreignContractID) + const existingForeignKeys = await sbp('chelonia/contract/foreignKeysByContractID', contractID, foreignContractID) if (existingForeignKeys?.includes(keyId)) { return @@ -586,15 +471,15 @@ export default (sbp('sbp/selectors/register', { const rootState = sbp('state/vuex/state') const state = rootState[contractID] // TODO: Also share PEK with DMs - await Promise.all(Object.keys(state.groups || {}).filter(groupID => !!rootState.contracts[groupID]).map(groupID => { - const CEKid = findKeyIdByName(rootState[groupID], 'cek') - const CSKid = findKeyIdByName(rootState[groupID], 'csk') + await Promise.all(Object.keys(state.groups || {}).filter(groupID => !!rootState.contracts[groupID]).map(async groupID => { + const CEKid = await sbp('chelonia/contract/currentKeyIdByName', groupID, 'cek') + const CSKid = await sbp('chelonia/contract/currentKeyIdByName', groupID, 'csk') if (!CEKid || !CSKid) { console.warn(`Unable to share rotated keys for ${contractID} with ${groupID}: Missing CEK or CSK`) // We intentionally don't throw here to be able to share keys with the // remaining groups - return Promise.resolve() + return } return sbp('chelonia/out/keyShare', { contractID: groupID, @@ -637,6 +522,7 @@ export default (sbp('sbp/selectors/register', { ...encryptedAction('gi.actions/identity/updateSettings', L('Failed to update profile settings.')), ...encryptedAction('gi.actions/identity/createDirectMessage', L('Failed to create a new direct message channel.'), async function (sendMessage, params) { const rootState = sbp('state/vuex/state') + // TODO: Can't use rootGetters const rootGetters = sbp('state/vuex/getters') const partnerIDs = params.data.memberIDs.map(memberID => rootGetters.ourContactProfilesById[memberID].contractID) // NOTE: 'rootState.currentGroupId' could be changed while waiting for the sbp functions to be proceeded @@ -713,7 +599,7 @@ export default (sbp('sbp/selectors/register', { }, // For now, we assume that we're messaging someone which whom we // share a group - signingKeyId: sbp('chelonia/contract/suitableSigningKey', partnerIDs[index], [GIMessage.OP_ACTION_ENCRYPTED], ['sig'], undefined, ['gi.contracts/identity/joinDirectMessage']), + signingKeyId: await sbp('chelonia/contract/suitableSigningKey', partnerIDs[index], [GIMessage.OP_ACTION_ENCRYPTED], ['sig'], undefined, ['gi.contracts/identity/joinDirectMessage']), innerSigningContractID: currentGroupId, hooks }) @@ -726,8 +612,7 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/identity/uploadFiles': async ({ attachments, billableContractID }: { attachments: Array, billableContractID: string }) => { - const rootGetters = sbp('state/vuex/getters') - + const { identityContractID } = sbp('state/vuex/state').loggedIn try { const attachmentsData = await Promise.all(attachments.map(async (attachment) => { const { mimeType, url } = attachment @@ -750,7 +635,7 @@ export default (sbp('sbp/selectors/register', { })) await sbp('gi.actions/identity/saveFileDeleteToken', { - contractID: rootGetters.ourIdentityContractId, + contractID: identityContractID, data: { tokensByManifestCid } }) @@ -763,21 +648,22 @@ export default (sbp('sbp/selectors/register', { 'gi.actions/identity/removeFiles': async ({ manifestCids, option }: { manifestCids: string[], option: Object }) => { - const rootGetters = sbp('state/vuex/getters') const { identityContractID } = sbp('state/vuex/state').loggedIn const { shouldDeleteFile, shouldDeleteToken, throwIfMissingToken } = option let deleteResult, toDelete + const currentIdentityState = await sbp('chelonia/contract/state', identityContractID) + if (shouldDeleteFile) { const credentials = Object.fromEntries(manifestCids.map(cid => { // It could be that the file was already deleted, if we no longer have // a delete token. In this case, omit those CIDs. - if (!throwIfMissingToken && shouldDeleteToken && !rootGetters.currentIdentityState.fileDeleteTokens[cid]) { + if (!throwIfMissingToken && shouldDeleteToken && !currentIdentityState.fileDeleteTokens[cid]) { console.info('[gi.actions/identity/removeFiles] Skipping file as token is missing', cid) return [cid, null] }; const credential = shouldDeleteToken - ? { token: rootGetters.currentIdentityState.fileDeleteTokens[cid] } + ? { token: currentIdentityState.fileDeleteTokens[cid] } : { billableContractID: identityContractID } return [cid, credential] })) diff --git a/frontend/controller/actions/index.js b/frontend/controller/actions/index.js index 680ac5606b..674f8c3525 100644 --- a/frontend/controller/actions/index.js +++ b/frontend/controller/actions/index.js @@ -44,7 +44,8 @@ sbp('sbp/selectors/register', { throw new Error('Missing CEK; unable to proceed sharing keys') } - const secretKeys = sbp('state/vuex/state')['secretKeys'] + // TODO: Use 'chelonia/haveSecretKey' + const secretKeys = await sbp('chelonia/rootState')['secretKeys'] const keysToShare = Array.isArray(keyIds) ? pick(secretKeys, keyIds) diff --git a/frontend/controller/actions/utils.js b/frontend/controller/actions/utils.js index e98eabeda0..032a0208fb 100644 --- a/frontend/controller/actions/utils.js +++ b/frontend/controller/actions/utils.js @@ -116,12 +116,12 @@ export const encryptedAction = ( ) const encryptionKeyId = params.encryptionKeyId || findKeyIdByName(state[contractID], encryptionKeyName ?? 'cek') - if (!signingKeyId || !encryptionKeyId || !sbp('chelonia/haveSecretKey', signingKeyId)) { + if (!signingKeyId || !encryptionKeyId || !await sbp('chelonia/haveSecretKey', signingKeyId)) { console.warn(`Refusing to send action ${action} due to missing CSK or CEK`, { contractID, action, signingKeyName, encryptionKeyName, signingKeyId, encryptionKeyId, signingContractID: params.signingContractID, originatingContractID: params.originatingContractID }) throw new GIErrorMissingSigningKeyError(`No key found to send ${action} for contract ${contractID}`) } - if (innerSigningContractID && (!innerSigningKeyId || !sbp('chelonia/haveSecretKey', innerSigningKeyId))) { + if (innerSigningContractID && (!innerSigningKeyId || !await sbp('chelonia/haveSecretKey', innerSigningKeyId))) { console.warn(`Refusing to send action ${action} due to missing inner signing key ID`, { contractID, action, signingKeyName, encryptionKeyName, signingKeyId, encryptionKeyId, signingContractID: params.signingContractID, originatingContractID: params.originatingContractID, innerSigningKeyId }) throw new GIErrorMissingSigningKeyError(`No key found to send ${action} for contract ${contractID}`) } @@ -222,12 +222,12 @@ export const encryptedNotification = ( ) const encryptionKeyId = params.encryptionKeyId || findKeyIdByName(state[contractID], encryptionKeyName ?? 'cek') - if (!signingKeyId || !encryptionKeyId || !sbp('chelonia/haveSecretKey', signingKeyId)) { + if (!signingKeyId || !encryptionKeyId || !await sbp('chelonia/haveSecretKey', signingKeyId)) { console.warn(`Refusing to send action ${action} due to missing CSK or CEK`, { contractID, action, signingKeyName, encryptionKeyName, signingKeyId, encryptionKeyId, signingContractID: params.signingContractID, originatingContractID: params.originatingContractID }) throw new GIErrorMissingSigningKeyError(`No key found to send ${action} for contract ${contractID}`) } - if (innerSigningContractID && (!innerSigningKeyId || !sbp('chelonia/haveSecretKey', innerSigningKeyId))) { + if (innerSigningContractID && (!innerSigningKeyId || !await sbp('chelonia/haveSecretKey', innerSigningKeyId))) { console.warn(`Refusing to send action ${action} due to missing inner signing key ID`, { contractID, action, signingKeyName, encryptionKeyName, signingKeyId, encryptionKeyId, signingContractID: params.signingContractID, originatingContractID: params.originatingContractID, innerSigningKeyId }) throw new GIErrorMissingSigningKeyError(`No key found to send ${action} for contract ${contractID}`) } @@ -252,28 +252,20 @@ export const encryptedNotification = ( } } -export async function createInvite ({ quantity = 1, creatorID, expires, invitee }: { - quantity: number, creatorID: string, expires: number, invitee?: string +export async function createInvite ({ contractID, quantity = 1, creatorID, expires, invitee }: { + contractID: string, quantity?: number, creatorID: string, expires: number, invitee?: string }): Promise<{inviteKeyId: string; creatorID: string; invitee?: string; }> { - const rootState = sbp('state/vuex/state') - - if (!rootState.currentGroupId) { - throw new Error('Current group not selected') - } - - const contractID = rootState.currentGroupId + const state = await sbp('chelonia/contract/state', contractID) if ( - !rootState[contractID] || - !rootState[contractID]._vm || - !findSuitableSecretKeyId(rootState[contractID], '*', ['sig']) || - rootState[contractID]._volatile?.pendingKeyRequests?.length + !state || + !state._vm || + !findSuitableSecretKeyId(state, '*', ['sig']) || + state._volatile?.pendingKeyRequests?.length ) { throw new Error('Invalid or missing current group state') } - const state = rootState[contractID] - const CEKid = findKeyIdByName(state, 'cek') const CSKid = findKeyIdByName(state, 'csk') diff --git a/frontend/controller/app/group.js b/frontend/controller/app/group.js new file mode 100644 index 0000000000..400274a214 --- /dev/null +++ b/frontend/controller/app/group.js @@ -0,0 +1,73 @@ +'use strict' + +import { L } from '@common/common.js' +import { + INVITE_INITIAL_CREATOR, + MAX_GROUP_MEMBER_COUNT +} from '@model/contracts/shared/constants.js' +import sbp from '@sbp/sbp' +import { OPEN_MODAL, REPLACE_MODAL, SWITCH_GROUP } from '@utils/events.js' +import ALLOWED_URLS from '@view-utils/allowedUrls.js' +import type { ChelKeyRequestParams } from '~/shared/domains/chelonia/chelonia.js' +import type { GIActionParams } from '../actions/types.js' + +export default (sbp('sbp/selectors/register', { + 'gi.app/group/createAndSwitch': async function (params: GIActionParams) { + const contractID = await sbp('gi.actions/group/create', params) + sbp('gi.app/group/switch', contractID, true) + return contractID + }, + 'gi.app/group/switch': function (groupId, isNewlyCreated) { + sbp('okTurtles.events/emit', SWITCH_GROUP, { contractID: groupId, isNewlyCreated }) + }, + 'gi.app/group/joinAndSwitch': async function (params: $Exact) { + await sbp('gi.actions/group/join', params) + // after joining, we can set the current group + return sbp('gi.app/group/switch', params.contractID, true) + }, + 'gi.app/group/joinWithInviteSecret': async function (groupId: string, secret: string) { + const result = await sbp('gi.actions/group/joinWithInviteSecret', groupId, secret) + sbp('gi.app/group/switch', groupId, true) + return result + }, + 'gi.app/group/addAndJoinChatRoom': async function (params: GIActionParams) { + const chatRoomID = await sbp('gi.actions/group/addAndJoinChatRoom', params) + sbp('state/vuex/commit', 'setPendingChatRoomId', { chatRoomID, groupID: params.contractID }) + return chatRoomID + }, + 'gi.app/group/checkGroupSizeAndProposeMember': async function () { + // if current size of the group is >= 150, display a warning prompt first before presenting the user with + // 'AddMembers' proposal modal. + + const enforceDunbar = true // Context for this hard-coded boolean variable: https://github.com/okTurtles/group-income/pull/1648#discussion_r1230389924 + const { groupMembersCount, currentGroupState } = sbp('state/vuex/getters') + const memberInvitesCount = Object.values(currentGroupState.invites || {}).filter((invite: any) => invite.creatorID !== INVITE_INITIAL_CREATOR).length + const isGroupSizeLarge = (groupMembersCount + memberInvitesCount) >= MAX_GROUP_MEMBER_COUNT + + if (isGroupSizeLarge) { + const translationArgs = { + a_: ``, + _a: '' + } + const promptConfig = enforceDunbar + ? { + heading: 'Large group size', + question: L("Group sizes are limited to {a_}Dunbar's Number{_a} to prevent fraud.", translationArgs), + primaryButton: L('OK') + } + : { + heading: 'Large group size', + question: L("Groups over 150 members are at significant risk for fraud, {a_}because it is difficult to verify everyone's identity.{_a} Are you sure that you want to add more members?", translationArgs), + primaryButton: L('Yes'), + secondaryButton: L('Cancel') + } + + const primaryButtonSelected = await sbp('gi.ui/prompt', promptConfig) + if (!enforceDunbar && primaryButtonSelected) { + sbp('okTurtles.events/emit', REPLACE_MODAL, 'AddMembers') + } else return false + } else { + sbp('okTurtles.events/emit', OPEN_MODAL, 'AddMembers') + } + } +}): string[]) diff --git a/frontend/controller/app/identity.js b/frontend/controller/app/identity.js new file mode 100644 index 0000000000..777d2805e0 --- /dev/null +++ b/frontend/controller/app/identity.js @@ -0,0 +1,252 @@ +'use strict' + +import { GIErrorUIRuntimeError, L, LError, LTags } from '@common/common.js' +import sbp from '@sbp/sbp' +import { LOGIN, LOGIN_ERROR } from '~/frontend/utils/events.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' +import { boxKeyPair, buildRegisterSaltRequest, computeCAndHc, decryptContractSalt, hash, hashPassword, randomNonce } from '~/shared/zkpp.js' +// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug +import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, deriveKeyFromPassword, serializeKey } from '../../../shared/domains/chelonia/crypto.js' +import { handleFetchResult } from '../utils/misc.js' +import { cloneDeep } from '@model/contracts/shared/giLodash.js' +import { LOGIN_COMPLETE } from '../../utils/events.js' + +const loadState = async (identityContractID: string, password: ?string) => { + if (password) { + const stateKeyEncryptionKeyFn = (stateEncryptionKeyId, salt) => { + return deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt + stateEncryptionKeyId) + } + + const { encryptionParams, value: state } = await sbp('gi.db/settings/loadEncrypted', identityContractID, stateKeyEncryptionKeyFn) + + if (state) { + const cheloniaState = state.cheloniaState + delete state.cheloniaState + + return { encryptionParams, state, cheloniaState } + } else { + return { encryptionParams, state, cheloniaState: null } + } + } else { + const state = await sbp('gi.db/settings/load', identityContractID) + + return { encryptionParams: null, state, cheloniaState: null } + } +} + +export default (sbp('sbp/selectors/register', { + 'gi.app/identity/retrieveSalt': async (username: string, password: Secret) => { + const r = randomNonce() + const b = hash(r) + const authHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/auth_hash?b=${encodeURIComponent(b)}`) + .then(handleFetchResult('json')) + + const { authSalt, s, sig } = authHash + + const h = await hashPassword(password.valueOf(), authSalt) + + const [c, hc] = computeCAndHc(r, s, h) + + const contractHash = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/${encodeURIComponent(username)}/contract_hash?${(new URLSearchParams({ + 'r': r, + 's': s, + 'sig': sig, + 'hc': Buffer.from(hc).toString('base64').replace(/\//g, '_').replace(/\+/g, '-').replace(/=*$/, '') + })).toString()}`).then(handleFetchResult('text')) + + return decryptContractSalt(c, contractHash) + }, + 'gi.app/identity/create': async function ({ + data: { username, email, password, picture }, + publishOptions + }) { + password = password.valueOf() + + // proceed with creation + const keyPair = boxKeyPair() + const r = Buffer.from(keyPair.publicKey).toString('base64').replace(/\//g, '_').replace(/\+/g, '-') + const b = hash(r) + // TODO: use the contractID instead, and move this code down below the registration + const registrationRes = await fetch(`${sbp('okTurtles.data/get', 'API_URL')}/zkpp/register/${encodeURIComponent(username)}`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded' + }, + body: `b=${encodeURIComponent(b)}` + }) + .then(handleFetchResult('json')) + + const { p, s, sig } = registrationRes + + const [contractSalt, Eh] = await buildRegisterSaltRequest(p, keyPair.secretKey, password) + + // Create the necessary keys to initialise the contract + const IPK = await deriveKeyFromPassword(EDWARDS25519SHA512BATCH, password, contractSalt) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, contractSalt) + + // next create the identity contract itself + try { + const userID = await sbp('gi.actions/identity/create', { + // TODO: Wrap IPK and IEK in "Secret" + IPK: serializeKey(IPK, true), + IEK: serializeKey(IEK, true), + publishOptions, + username, + email, + picture, + r, + s, + sig, + Eh + }) + + return userID + } catch (e) { + console.error('gi.app/identity/create failed!', e) + throw new GIErrorUIRuntimeError(L('Failed to create user identity: {reportError}', LError(e))) + } + }, + 'gi.app/identity/signup': async function ({ username, email, password }, publishOptions) { + try { + const randomAvatar = sbp('gi.utils/avatar/create') + const userID = await sbp('gi.app/identity/create', { + data: { + username, + email, + password, + picture: randomAvatar + }, + publishOptions + }) + return userID + } catch (e) { + console.error('gi.app/identity/signup failed!', e) + await sbp('gi.app/identity/logout') // TODO: should this be here? + const message = LError(e) + if (e.name === 'GIErrorUIRuntimeError') { + // 'gi.app/identity/create' also sets reportError + message.reportError = e.message + } + throw new GIErrorUIRuntimeError(L('Failed to signup: {reportError}', message)) + } + }, + 'gi.app/identity/login': async function ({ username, password: wpassword, identityContractID }: { + username: ?string, password: ?Secret, identityContractID: ?string + }) { + if (username) { + identityContractID = await sbp('namespace/lookup', username) + } + + if (!identityContractID) { + throw new GIErrorUIRuntimeError(L('Incorrect username or password')) + } + + const password = wpassword?.valueOf() + const transientSecretKeys = [] + if (password) { + try { + const salt = await sbp('gi.app/identity/retrieveSalt', username, wpassword) + const IEK = await deriveKeyFromPassword(CURVE25519XSALSA20POLY1305, password, salt) + transientSecretKeys.push(IEK) + } catch (e) { + console.error('caught error calling retrieveSalt:', e) + throw new GIErrorUIRuntimeError(L('Incorrect username or password')) + } + } + + try { + sbp('appLogs/startCapture', identityContractID) + const { state, cheloniaState, encryptionParams } = await loadState(identityContractID, password) + let loginCompleteHandler, loginErrorHandler + + try { + const loginCompletePromise = new Promise((resolve, reject) => { + const loginCompleteHandler = ({ identityContractID: id }) => { + sbp('okTurtles.events/off', LOGIN_ERROR, loginErrorHandler) + if (id === identityContractID) { + // We need to save the state to ensure it's possible to refresh + // the page + resolve(sbp('state/vuex/save')) + } else { + reject(new Error('Unexpected identity contract ID')) + } + } + const loginErrorHandler = ({ identityContractID: id, error }) => { + sbp('okTurtles.events/off', LOGIN_COMPLETE, loginCompleteHandler) + if (id === identityContractID) { + reject(error) + } else { + reject(new Error('Unexpected identity contract ID')) + } + } + + sbp('okTurtles.events/once', LOGIN_COMPLETE, loginCompleteHandler) + sbp('okTurtles.events/once', LOGIN_ERROR, loginErrorHandler) + }) + + if (password) { + await sbp('gi.actions/identity/login', { identityContractID, encryptionParams, cheloniaState, state, transientSecretKeys: transientSecretKeys.map(k => new Secret(serializeKey(k, true))) }) + } else { + sbp('okTurtles.events/emit', LOGIN, { identityContractID, state }) + } + + await loginCompletePromise + } catch (e) { + sbp('okTurtles.events/off', LOGIN_COMPLETE, loginCompleteHandler) + sbp('okTurtles.events/off', LOGIN_ERROR, loginErrorHandler) + + const errMessage = e?.message || String(e) + console.error('Error during login contract sync', e) + + const promptOptions = { + heading: L('Login error'), + question: L('Do you want to log out? {br_}Error details: {err}.', { err: errMessage, ...LTags() }), + primaryButton: L('No'), + secondaryButton: L('Yes') + } + + const result = await sbp('gi.ui/prompt', promptOptions) + if (!result) { + return sbp('gi.app/identity/logout') + } else { + sbp('okTurtles.events/emit', LOGIN_ERROR, { username, identityContractID, error: e }) + throw e + } + } + + return identityContractID + } catch (e) { + console.error('gi.app/identity/login failed!', e) + const humanErr = L('Failed to login: {reportError}', LError(e)) + alert(humanErr) + await sbp('gi.app/identity/logout') + .catch((e) => { + console.error('[gi.app/identity/login] Error calling logout (after failure to login)', e) + }) + throw new GIErrorUIRuntimeError(humanErr) + } + }, + 'gi.app/identity/signupAndLogin': async function ({ username, email, password }) { + const contractIDs = await sbp('gi.app/identity/signup', { username, email, password }) + await sbp('gi.app/identity/login', { username, password }) + return contractIDs + }, + 'gi.app/identity/logout': async function () { + try { + const state = cloneDeep(sbp('state/vuex/state')) + if (!state.loggedIn) return + + const cheloniaState = await sbp('gi.actions/identity/logout') + + const { encryptionParams } = state.loggedIn + if (encryptionParams) { + state.cheloniaState = cheloniaState + + await sbp('state/vuex/save', true, state) + await sbp('gi.db/settings/deleteStateEncryptionKey', encryptionParams) + } + } catch (e) { + console.error(`${e.name} during logout: ${e.message}`, e) + } + } +}): string[]) diff --git a/frontend/controller/app/index.js b/frontend/controller/app/index.js new file mode 100644 index 0000000000..655b8f1af7 --- /dev/null +++ b/frontend/controller/app/index.js @@ -0,0 +1,2 @@ +export { default as group } from './group.js' +export { default as identity } from './identity.js' diff --git a/frontend/controller/namespace.js b/frontend/controller/namespace.js index 5fc3a4cdaf..41c6ad1162 100644 --- a/frontend/controller/namespace.js +++ b/frontend/controller/namespace.js @@ -5,30 +5,9 @@ import Vue from 'vue' // NOTE: prefix groups with `group/` and users with `user/` ? sbp('sbp/selectors/register', { - /* - // Registration is done when creating a contract, using the - // `shelter-namespace-registration` header - 'namespace/register': (name: string, value: string) => { - return fetch(`${sbp('okTurtles.data/get', 'API_URL')}/name`, { - method: 'POST', - body: JSON.stringify({ name, value }), - headers: { - 'Content-Type': 'application/json' - } - }).then(handleFetchResult('json')).then(result => { - Vue.set(sbp('state/vuex/state').namespaceLookups, name, value) - return result - }) - }, - */ 'namespace/lookupCached': (name: string) => { const cache = sbp('state/vuex/state').namespaceLookups - if (name in cache) { - // Wrapping in a Promise to return a consistent type across all execution - // paths (next return is a Promise) - // This way we can call .then() on the result - return cache[name] - } + return cache?.[name] ?? null }, 'namespace/lookup': (name: string, { skipCache }: { skipCache: boolean } = { skipCache: false }) => { if (!skipCache) { diff --git a/frontend/controller/service-worker.js b/frontend/controller/service-worker.js index 1b60eaa167..687e6ab83a 100644 --- a/frontend/controller/service-worker.js +++ b/frontend/controller/service-worker.js @@ -22,6 +22,12 @@ sbp('sbp/selectors/register', { // if an active service-worker exists, checks for the updates immediately first and then repeats it every 1hr await swRegistration.update() setInterval(() => sbp('service-worker/update'), HOURS_MILLIS) + + // Keep the service worker alive while the window is open + // The default idle timeout on Chrome and Firefox is 30 seconds. We send + // a ping message every 5 seconds to ensure that the worker remains + // active. + setInterval(() => navigator.serviceWorker.controller?.postMessage({ type: 'ping' }), 5000) } navigator.serviceWorker.addEventListener('message', event => { @@ -30,6 +36,8 @@ sbp('sbp/selectors/register', { if (typeof data === 'object' && data.type) { switch (data.type) { + case 'pong': + break case 'pushsubscriptionchange': { sbp('service-worker/resubscribe-push', data.subscription) break diff --git a/frontend/controller/serviceworkers/sw-primary.js b/frontend/controller/serviceworkers/sw-primary.js index 62b6a0cb55..9d4fa94bd2 100644 --- a/frontend/controller/serviceworkers/sw-primary.js +++ b/frontend/controller/serviceworkers/sw-primary.js @@ -57,6 +57,18 @@ self.addEventListener('message', function (event) { case 'store-client-id': store.clientId = event.source.id break + case 'ping': + event.source.postMessage({ type: 'pong' }) + break + case 'shutdown': + self.registration.unregister() + .then(function () { + return self.clients.matchAll() + }) + .then(function (clients) { + clients.forEach(client => client.navigate(client.url)) + }) + break default: console.error('[sw] unknown message type:', event.data) break diff --git a/frontend/main.js b/frontend/main.js index 049042c082..511e98d7ff 100644 --- a/frontend/main.js +++ b/frontend/main.js @@ -1,48 +1,50 @@ 'use strict' // import SBP stuff before anything else so that domains register themselves before called -import sbp from '@sbp/sbp' +import * as Common from '@common/common.js' +import '@model/captureLogs.js' import '@sbp/okturtles.data' -import '@sbp/okturtles.events' import '@sbp/okturtles.eventqueue' +import '@sbp/okturtles.events' +import sbp from '@sbp/sbp' +import ALLOWED_URLS from '@view-utils/allowedUrls.js' import IdleVue from 'idle-vue' -import { mapMutations, mapGetters, mapState } from 'vuex' +import { mapGetters, mapMutations, mapState } from 'vuex' import 'wicg-inert' -import '@model/captureLogs.js' -import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' import '~/shared/domains/chelonia/chelonia.js' -import { CONTRACT_IS_SYNCING } from '~/shared/domains/chelonia/events.js' +import type { GIMessage } from '~/shared/domains/chelonia/chelonia.js' +import { CONTRACTS_MODIFIED, CONTRACT_IS_SYNCING, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js' +import '~/shared/domains/chelonia/persistent-actions.js' import { NOTIFICATION_TYPE, REQUEST_TYPE } from '../shared/pubsub.js' -import * as Common from '@common/common.js' -import { LOGIN, LOGOUT, LOGIN_ERROR, SWITCH_GROUP, THEME_CHANGE, CHATROOM_USER_TYPING, CHATROOM_USER_STOP_TYPING } from './utils/events.js' -import './controller/namespace.js' import './controller/actions/index.js' +import './controller/app/index.js' import './controller/backend.js' +import { PUBSUB_INSTANCE } from './controller/instance-keys.js' +import './controller/namespace.js' +import router from './controller/router.js' import './controller/service-worker.js' -import '~/shared/domains/chelonia/persistent-actions.js' import manifests from './model/contracts/manifests.json' -import router from './controller/router.js' -import { PUBSUB_INSTANCE } from './controller/instance-keys.js' -import store from './model/state.js' import { SETTING_CURRENT_USER } from './model/database.js' -import BackgroundSounds from './views/components/sounds/Background.vue' -import BannerGeneral from './views/components/banners/BannerGeneral.vue' -import Navigation from './views/containers/navigation/Navigation.vue' +import store from './model/state.js' +import { CHATROOM_USER_STOP_TYPING, CHATROOM_USER_TYPING, DELETED_CHATROOM, JOINED_CHATROOM, JOINED_GROUP, LEFT_CHATROOM, LEFT_GROUP, LOGIN, LOGIN_COMPLETE, LOGIN_ERROR, LOGOUT, SHELTER_EVENT_HANDLED, SWITCH_GROUP, THEME_CHANGE } from './utils/events.js' import AppStyles from './views/components/AppStyles.vue' +import BannerGeneral from './views/components/banners/BannerGeneral.vue' import Modal from './views/components/modal/Modal.vue' -import ALLOWED_URLS from '@view-utils/allowedUrls.js' +import BackgroundSounds from './views/components/sounds/Background.vue' +import Navigation from './views/containers/navigation/Navigation.vue' import './views/utils/avatar.js' import './views/utils/ui.js' -import './views/utils/vFocus.js' import './views/utils/vError.js' +import './views/utils/vFocus.js' // import './views/utils/vSafeHtml.js' // this gets imported by translations, which is part of common.js -import './views/utils/vStyle.js' -import './utils/touchInteractions.js' -import './model/notifications/periodicNotifications.js' +import { cloneDeep, debounce, has } from '@model/contracts/shared/giLodash.js' import notificationsMixin from './model/notifications/mainNotificationsMixin.js' -import { showNavMixin } from './views/utils/misc.js' -import FaviconBadge from './utils/faviconBadge.js' +import './model/notifications/periodicNotifications.js' import { KV_KEYS } from './utils/constants.js' +import FaviconBadge from './utils/faviconBadge.js' +import './utils/touchInteractions.js' +import { showNavMixin } from './views/utils/misc.js' +import './views/utils/vStyle.js' const { Vue, L } = Common @@ -51,6 +53,17 @@ console.info('CONTRACTS_VERSION:', process.env.CONTRACTS_VERSION) console.info('LIGHTWEIGHT_CLIENT:', process.env.LIGHTWEIGHT_CLIENT) console.info('NODE_ENV:', process.env.NODE_ENV) +;(() => { + const _fetch = window.fetch + window.fetch = (...args) => { + const stack = new Error('@@@FETCH ERROR').stack + return _fetch(...args).catch(e => { + console.error('@@@FETCH ERROR', ...args, stack, e) + throw e + }) + } +})() + Vue.config.errorHandler = function (err, vm, info) { console.error(`uncaught Vue error in ${info}:`, err) // Fix for https://github.com/okTurtles/group-income/issues/684 @@ -103,11 +116,97 @@ async function startApp () { // Since a runtime error just occured, we likely want to persist app logs to local storage now. sbp('appLogs/save') } + + sbp('okTurtles.events/on', JOINED_CHATROOM, ({ identityContractID, groupContractID, chatRoomID }) => { + const rootState = sbp('state/vuex/state') + if (rootState.loggedIn?.identityContractID !== identityContractID) return + if (!rootState.chatroom.currentChatRoomIDs[groupContractID] || rootState.chatroom.pendingChatRoomIDs[groupContractID] === chatRoomID) { + sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupID: groupContractID, chatRoomID }) + } + }) + + sbp('okTurtles.events/on', JOINED_GROUP, ({ identityContractID, groupContractID }) => { + const rootState = sbp('state/vuex/state') + if (rootState.loggedIn?.identityContractID !== identityContractID) return + if (!rootState[groupContractID]) return + if (!rootState.currentGroupId) { + sbp('state/vuex/commit', 'setCurrentGroupId', { contractID: groupContractID }) + sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) + } + }) + + const switchCurrentChatRoomHandler = ({ identityContractID, groupContractID, chatRoomID }) => { + const rootState = sbp('state/vuex/state') + if (identityContractID && rootState.loggedIn?.identityContractID !== identityContractID) return + if (!rootState[groupContractID]) return + if (rootState.chatroom.currentChatRoomIDs[groupContractID] === chatRoomID) { + const id = rootState[groupContractID].generalChatRoomId || Object.entries(rootState[groupContractID].chatRooms).find(([id, value]) => { + // $FlowFixMe[incompatible-use] + return id !== chatRoomID && value.members[identityContractID]?.status === 'active' + })?.[0] + if (id) { + sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupID: groupContractID, chatRoomID: id }) + } + } + } + + sbp('okTurtles.events/on', LEFT_CHATROOM, switchCurrentChatRoomHandler) + sbp('okTurtles.events/on', DELETED_CHATROOM, switchCurrentChatRoomHandler) + + sbp('okTurtles.events/on', LEFT_GROUP, ({ identityContractID, groupContractID }) => { + const rootState = sbp('state/vuex/state') + console.error('@@@@@yy LEFT GROUP', identityContractID, groupContractID) + if (rootState.loggedIn?.identityContractID !== identityContractID) return + const state = rootState[identityContractID] + // grab the groupID of any group that we're a part of + const currentGroupId = rootState.currentGroupId + if (!currentGroupId || currentGroupId === groupContractID) { + const groupIdToSwitch = Object.keys(state.groups) + .filter(cID => + cID !== groupContractID + ).sort(cID => + // prefer successfully joined groups + sbp('state/vuex/state')[cID]?.profiles?.[groupContractID] ? -1 : 1 + )[0] || null + sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) + sbp('state/vuex/commit', 'setCurrentGroupId', { contractID: groupIdToSwitch }) + if (currentGroupId === groupContractID) { + sbp('controller/router').push({ path: '/' }).catch(() => {}) + } + } + }) + + await sbp('gi.db/settings/load', 'CHELONIA_STATE').then(async (cheloniaState) => { + // TODO: PLACEHOLDER TO SIMULATE CHELONIA IN A SW + if (!cheloniaState) return + const identityContractID = await sbp('gi.db/settings/load', SETTING_CURRENT_USER) + if (!identityContractID) return + Object.assign(sbp('chelonia/rootState'), cheloniaState) + console.error('@@@@SET CHELONIA STATE[main.js]', identityContractID, sbp('chelonia/rootState'), cheloniaState) + }) + + const saveChelonia = () => sbp('okTurtles.eventQueue/queueEvent', 'CHELONIA_STATE', () => { + return sbp('gi.db/settings/save', 'CHELONIA_STATE', sbp('chelonia/rootState')) + }) + const saveCheloniaDebounced = debounce(saveChelonia, 200) + await sbp('chelonia/configure', { connectionURL: sbp('okTurtles.data/get', 'API_URL'), - stateSelector: 'state/vuex/state', - reactiveSet: Vue.set, - reactiveDel: Vue.delete, + // stateSelector: 'state/vuex/state', + reactiveSet: (o: Object, k: string, v: string) => { + // Simulate SW environment + if (o[k] !== v) { + o[k] = v + saveCheloniaDebounced() + } + }, + reactiveDel: (o: Object, k: string) => { + // Simulate SW environment + if (has(o, k)) { + delete o[k] + saveCheloniaDebounced() + } + }, contracts: { ...manifests, defaults: { @@ -115,7 +214,7 @@ async function startApp () { allowedSelectors: [ 'namespace/lookup', 'namespace/lookupCached', 'state/vuex/state', 'state/vuex/settings', 'state/vuex/commit', 'state/vuex/getters', - 'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'chelonia/contract/retain', 'chelonia/contract/release', 'controller/router', + 'chelonia/rootState', 'chelonia/contract/state', 'chelonia/contract/sync', 'chelonia/contract/isSyncing', 'chelonia/contract/remove', 'chelonia/contract/retain', 'chelonia/contract/release', 'controller/router', 'chelonia/contract/suitableSigningKey', 'chelonia/contract/currentKeyIdByName', 'chelonia/storeSecretKeys', 'chelonia/crypto/keyId', 'chelonia/queueInvocation', 'chelonia/contract/wait', @@ -125,6 +224,7 @@ async function startApp () { 'gi.actions/group/removeOurselves', 'gi.actions/group/groupProfileUpdate', 'gi.actions/group/displayMincomeChangedPrompt', 'gi.actions/group/addChatRoom', 'gi.actions/group/join', 'gi.actions/group/joinChatRoom', 'gi.actions/identity/addJoinDirectMessageKey', 'gi.actions/identity/leaveGroup', + 'gi.actions/chatroom/delete', 'gi.notifications/emit', 'gi.actions/out/rotateKeys', 'gi.actions/group/shareNewKeys', 'gi.actions/chatroom/shareNewKeys', 'gi.actions/identity/shareNewPEK', 'chelonia/out/keyDel', @@ -175,6 +275,64 @@ async function startApp () { } }) + sbp('okTurtles.events/on', EVENT_HANDLED, async (contractID, message) => { + // TODO: WRITE THIS MORE EFFICIENTLY SO THAT ONLY THE RELEVANT PARTS ARE + // COPIED INSTEAD OF THE ENTIRE CHELONIA STATE + const cheloniaState = await sbp('chelonia/rootState') + const state = cheloniaState[contractID] + const contractState = cheloniaState.contracts[contractID] + const vuexState = sbp('state/vuex/state') + if (contractState) { + if (!vuexState.contracts) { + Vue.set(vuexState, 'contracts', Object.create(null)) + } + Vue.set(vuexState.contracts, contractID, cloneDeep(contractState)) + } else if (vuexState.contracts) { + Vue.delete(vuexState.contracts, contractState) + } + if (state) { + Vue.set(vuexState, contractID, cloneDeep(state)) + } else { + Vue.delete(vuexState, contractID) + } + sbp('okTurtles.events/emit', SHELTER_EVENT_HANDLED, contractID, message) + }) + + sbp('okTurtles.events/on', CONTRACTS_MODIFIED, async (subscriptionSet) => { + // TODO: WRITE THIS MORE EFFICIENTLY SO THAT ONLY THE RELEVANT PARTS ARE + // COPIED INSTEAD OF THE ENTIRE CHELONIA STATE + const cheloniaState = await sbp('chelonia/rootState') + const vuexState = sbp('state/vuex/state') + + if (!vuexState.contracts) { + Vue.set(vuexState, 'contracts', Object.create(null)) + } + + const oldContracts = Object.keys(vuexState.contracts) + const oldContractsToRemove = oldContracts.filter(x => !subscriptionSet.includes(x)) + const newContracts = subscriptionSet.filter(x => !oldContracts.includes(x)) + + oldContractsToRemove.forEach(x => { + Vue.delete(vuexState.contracts, x) + Vue.delete(vuexState, x) + }) + newContracts.forEach(x => { + const state = cheloniaState[x] + const contractState = cheloniaState.contracts[x] + if (contractState) { + Vue.set(vuexState.contracts, x, cloneDeep(contractState)) + } + if (state) { + Vue.set(vuexState, x, cloneDeep(state)) + } + }) + }) + + /* sbp('okTurtles.events/on', NAMESPACE_REGISTRATION, ({ name, value }) => { + const cache = sbp('state/vuex/state').namespaceLookups + Vue.set(cache, name, value) + }) */ + // NOTE: setting 'EXPOSE_SBP' in production will make it easier for users to generate contract // actions that they shouldn't be generating, which can lead to bugs or trigger the automated // ban system. Only enable it if you know what you're doing and don't mind the risk. @@ -272,7 +430,14 @@ async function startApp () { } // register service-worker - await sbp('service-workers/setup') + /* await Promise.race( + [sbp('service-workers/setup'), + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('Timed out setting up service worker')) + }, 15e3) + })] + ) */ /* eslint-disable no-new */ new Vue({ @@ -321,34 +486,128 @@ async function startApp () { } sbp('okTurtles.events/off', CONTRACT_IS_SYNCING, initialSyncFn) sbp('okTurtles.events/on', CONTRACT_IS_SYNCING, syncFn.bind(this)) - sbp('okTurtles.events/on', LOGIN, async () => { - this.ephemeral.finishedLogin = 'yes' + sbp('okTurtles.events/on', LOGIN, async ({ identityContractID, encryptionParams, state }) => { + try { + const vuexState = sbp('state/vuex/state') + if (vuexState.loggedIn && vuexState.loggedIn.identityContractID !== identityContractID) { + throw new Error('Received login event but there already is an active session') + } + const cheloniaState = cloneDeep(await sbp('chelonia/rootState')) + if (state) { + // TODO Do this in a cleaner way + // Exclude contracts from the state + Object.keys(state).forEach(k => { + if (k.startsWith('z9br')) { + delete state[k] + } + }) + Object.keys(cheloniaState.contracts).forEach(k => { + if (cheloniaState[k]) { + state[k] = cheloniaState[k] + } + }) + state.contracts = cheloniaState.contracts + if (cheloniaState.namespaceLookups) { + state.namespaceLookups = cheloniaState.namespaceLookups + } + // End exclude contracts + sbp('state/vuex/postUpgradeVerification', state) + sbp('state/vuex/replace', state) + } else { + const state = vuexState + // Exclude contracts from the state + Object.keys(state).forEach(k => { + if (k.startsWith('z9br')) { + Vue.delete(state, k) + } + }) + Object.keys(cheloniaState.contracts).forEach(k => { + if (cheloniaState[k]) { + Vue.set(state, k, cheloniaState[k]) + } + }) + Vue.set(state, 'contracts', cheloniaState.contracts) + if (cheloniaState.namespaceLookups) { + Vue.set(state, 'namespaceLookups', cheloniaState.namespaceLookups) + } + // End exclude contracts + } - if (this.$store.state.currentGroupId) { - this.initOrResetPeriodicNotifications() - this.checkAndEmitOneTimeNotifications() - } - const databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}` - sbp('chelonia.persistentActions/configure', { databaseKey }) - await sbp('chelonia.persistentActions/load') + if (encryptionParams) { + sbp('state/vuex/commit', 'login', { identityContractID, encryptionParams }) + } + + // NOTE: users could notice that they leave the group by someone + // else when they log in + const currentState = sbp('state/vuex/state') + if (!currentState.currentGroupId) { + const gId = Object.keys(currentState.contracts) + .find(cID => has(currentState[identityContractID].groups, cID)) + + if (gId) { + sbp('gi.app/group/switch', gId) + } + } + + // Whenever there's an active session, the encrypted save state should be + // removed, as it is only used for recovering the state when logging in + sbp('gi.db/settings/deleteEncrypted', identityContractID).catch(e => { + console.error('Error deleting encrypted settings after login') + }) - // NOTE: should set IdleVue plugin here because state could be replaced while logging in - Vue.use(IdleVue, { store, idleTime: 2 * 60 * 1000 }) // 2 mins of idle config + this.ephemeral.finishedLogin = 'yes' + + if (this.$store.state.currentGroupId) { + this.initOrResetPeriodicNotifications() + this.checkAndEmitOneTimeNotifications() + } + const databaseKey = `chelonia/persistentActions/${sbp('state/vuex/getters').ourIdentityContractId}` + sbp('chelonia.persistentActions/configure', { databaseKey }) + await sbp('chelonia.persistentActions/load') + + // NOTE: should set IdleVue plugin here because state could be replaced while logging in + Vue.use(IdleVue, { store, idleTime: 2 * 60 * 1000 }) // 2 mins of idle config + + // TODO: [SW] This should be done by the service worker when logging + // in + await saveChelonia() + sbp('okTurtles.events/emit', LOGIN_COMPLETE, { identityContractID }) + } catch (e) { + sbp('okTurtles.events/emit', LOGIN_ERROR, { identityContractID, error: e }) + } }) sbp('okTurtles.events/on', LOGOUT, () => { + Promise.all([ + sbp('chelonia/reset'), + sbp('gi.db/settings/delete', 'CHELONIA_STATE') + ]).catch(e => { + console.error('Logout event: error deleting Chelonia state') + }).then(() => { + console.error('@@@LOGOUT DONE') + }) + console.error('@@@@LOGOUT ev') + }) + sbp('okTurtles.events/on', LOGOUT, () => { + const state = sbp('state/vuex/state') + if (!state.loggedIn) return this.ephemeral.finishedLogin = 'no' - router.currentRoute.path !== '/' && router.push({ path: '/' }).catch(console.error) // Stop timers related to periodic notifications or persistent actions. sbp('gi.periodicNotifications/clearStatesAndStopTimers') + sbp('gi.db/settings/delete', state.loggedIn.identityContractID).catch(e => { + console.error('Logout event: error deleting settings') + }) + sbp('state/vuex/reset') sbp('chelonia.persistentActions/unload') + router.currentRoute.path !== '/' && router.push({ path: '/' }).catch(console.error) }) sbp('okTurtles.events/once', LOGIN_ERROR, () => { // Remove the loading animation that sits on top of the Vue app, so that users can properly interact with the app for a follow-up action. this.removeLoadingAnimation() }) - sbp('okTurtles.events/on', SWITCH_GROUP, () => { + sbp('okTurtles.events/on', SWITCH_GROUP, ({ contractID, isNewlyCreated }) => { this.initOrResetPeriodicNotifications() this.checkAndEmitOneTimeNotifications() + sbp('state/vuex/commit', 'setCurrentGroupId', { contractID, isNewlyCreated }) }) sbp('okTurtles.data/apply', PUBSUB_INSTANCE, (pubsub) => { @@ -405,9 +664,33 @@ async function startApp () { // to ensure that we don't override user interactions that have already // happened (an example where things can happen this quickly is in the // tests). - sbp('gi.db/settings/load', SETTING_CURRENT_USER).then(identityContractID => { - if (!identityContractID || this.ephemeral.finishedLogin === 'yes') return - return sbp('gi.actions/identity/login', { identityContractID }).catch((e) => { + sbp('gi.db/settings/load', SETTING_CURRENT_USER).then(async (identityContractID) => { + // This loads CHELONIA_STATE when _not_ running as a service worker + const cheloniaState = await sbp('gi.db/settings/load', 'CHELONIA_STATE') + if (!cheloniaState || !identityContractID) return + console.error('@@@@MAIN PAGE LOAD', { cSICID: cheloniaState.loggedIn?.identityContractID, identityContractID }) + if (cheloniaState.loggedIn?.identityContractID !== identityContractID) return + const contractSyncPriorityList = [ + 'gi.contracts/identity', + 'gi.contracts/group', + 'gi.contracts/chatroom' + ] + const getContractSyncPriority = (key) => { + const index = contractSyncPriorityList.indexOf(key) + return index === -1 ? contractSyncPriorityList.length : index + } + await sbp('chelonia/contract/sync', identityContractID, { force: true }) + const contractIDs = Object.keys(cheloniaState.contracts) + await Promise.all(Object.entries(contractIDs).sort(([a], [b]) => { + // Sync contracts in order based on type + return getContractSyncPriority(a) - getContractSyncPriority(b) + }).map(([, ids]) => { + return sbp('okTurtles.eventQueue/queueEvent', `appStart:${identityContractID ?? '(null)'}`, ['chelonia/contract/sync', ids, { force: true }]) + })) + console.error('@@@WILL CALL LOGIN WITH ', identityContractID) + + if (this.ephemeral.finishedLogin === 'yes') return + return sbp('gi.app/identity/login', { identityContractID }).catch((e) => { console.error(`[main] caught ${e?.name} while logging in: ${e?.message || e}`, e) console.warn(`It looks like the local user '${identityContractID}' does not exist anymore on the server đŸ˜± If this is unexpected, contact us at https://gitter.im/okTurtles/group-income`) }) diff --git a/frontend/model/chatroom/vuexModule.js b/frontend/model/chatroom/vuexModule.js index d8ab557fb5..36f2f8c9d8 100644 --- a/frontend/model/chatroom/vuexModule.js +++ b/frontend/model/chatroom/vuexModule.js @@ -6,6 +6,7 @@ import { merge, cloneDeep, union } from '@model/contracts/shared/giLodash.js' import { MESSAGE_NOTIFY_SETTINGS, CHATROOM_PRIVACY_LEVEL } from '@model/contracts/shared/constants.js' const defaultState = { currentChatRoomIDs: {}, // { [groupId]: currentChatRoomId } + pendingChatRoomIDs: {}, // { [groupId]: currentChatRoomId } chatRoomScrollPosition: {}, // [chatRoomID]: messageHash unreadMessages: null, // [chatRoomID]: { readUntil: { messageHash, createdHeight }, unreadMessages: [{ messageHash, createdHeight }]} chatNotificationSettings: {} // { [chatRoomID]: { messageNotification: MESSAGE_NOTIFY_SETTINGS, messageSound: MESSAGE_NOTIFY_SETTINGS } } @@ -182,6 +183,21 @@ const getters = { return getters.groupMembersSorted .map(member => ({ contractID: member.contractID, username: member.username, displayName: member.displayName })) .filter(member => !!getters.chatRoomMembers[member.contractID]) || [] + }, + chatRoomSettings (state, getters, rootState) { + return rootState[getters.currentChatRoomId]?.settings || {} + }, + chatRoomAttributes (state, getters, rootState) { + return rootState[getters.currentChatRoomId]?.attributes || {} + }, + chatRoomMembers (state, getters, rootState) { + return rootState[getters.currentChatRoomId]?.members || {} + }, + chatRoomRecentMessages (state, getters, rootState) { + return rootState[getters.currentChatRoomId]?.messages || [] + }, + chatRoomPinnedMessages (state, getters, rootState) { + return (rootState[getters.currentChatRoomId]?.pinnedMessages || []).sort((a, b) => a.height < b.height ? 1 : -1) } } @@ -196,10 +212,31 @@ const mutations = { } else { Vue.set(state.currentChatRoomIDs, groupID, rootState[groupID].generalChatRoomId || null) } - } else if (chatRoomID) { - Vue.set(state.currentChatRoomIDs, rootState.currentGroupId, chatRoomID) + Vue.delete(state.pendingChatRoomIDs, groupID) } else { - Vue.set(state.currentChatRoomIDs, rootState.currentGroupId, null) + if (chatRoomID) { + Vue.set(state.currentChatRoomIDs, rootState.currentGroupId, chatRoomID) + } else { + Vue.set(state.currentChatRoomIDs, rootState.currentGroupId, null) + } + Vue.delete(state.pendingChatRoomIDs, rootState.currentGroupId) + } + }, + setPendingChatRoomId (state, { groupID, chatRoomID }) { + const rootState = sbp('state/vuex/state') + const rootGetters = sbp('state/vuex/getters') + + if (rootGetters.isJoinedChatRoom(chatRoomID)) { + mutations.setCurrentChatRoomId(state, { groupID, chatRoomID }) + return + } + + if (groupID && rootState[groupID]) { + if (chatRoomID) { + Vue.set(state.pendingChatRoomIDs, groupID, chatRoomID) + } else { + Vue.set(state.pendingChatRoomIDs, groupID, null) + } } }, setUnreadMessages (state, value) { diff --git a/frontend/model/contracts/chatroom.js b/frontend/model/contracts/chatroom.js index 374f5f7737..dc8117702d 100644 --- a/frontend/model/contracts/chatroom.js +++ b/frontend/model/contracts/chatroom.js @@ -47,7 +47,7 @@ function createNotificationData ( } } -function messageReceivePostEffect ({ +async function messageReceivePostEffect ({ contractID, messageHash, height, text, isDMOrMention, messageType, memberID, chatRoomName }: { @@ -59,8 +59,9 @@ function messageReceivePostEffect ({ isDMOrMention: boolean, memberID: string, chatRoomName: string -}): void { - const rootGetters = sbp('state/vuex/getters') +}): Promise { + // TODO: This can't be a root getter when running in a SW + const rootGetters = await sbp('state/vuex/getters') const isDirectMessage = rootGetters.isDirectMessage(contractID) const shouldAddToUnreadMessages = isDMOrMention || [MESSAGE_TYPES.INTERACTIVE, MESSAGE_TYPES.POLL].includes(messageType) @@ -129,26 +130,7 @@ sbp('chelonia/defineContract', { } } }, - getters: { - currentChatRoomState (state) { - return state - }, - chatRoomSettings (state, getters) { - return getters.currentChatRoomState.settings || {} - }, - chatRoomAttributes (state, getters) { - return getters.currentChatRoomState.attributes || {} - }, - chatRoomMembers (state, getters) { - return getters.currentChatRoomState.members || {} - }, - chatRoomRecentMessages (state, getters) { - return getters.currentChatRoomState.messages || [] - }, - chatRoomPinnedMessages (state, getters) { - return (getters.currentChatRoomState.pinnedMessages || []).sort((a, b) => a.height < b.height ? 1 : -1) - } - }, + getters: {}, actions: { // This is the constructor of Chat contract 'gi.contracts/chatroom': { @@ -215,35 +197,31 @@ sbp('chelonia/defineContract', { ) addMessage(state, createMessage({ meta, hash, height, state, data: notificationData, innerSigningContractID })) }, - sideEffect ({ data, contractID, hash, height, meta, innerSigningContractID }, { state }) { - sbp('chelonia/queueInvocation', contractID, () => { - const rootGetters = sbp('state/vuex/getters') - const state = sbp('state/vuex/state')[contractID] - const loggedIn = sbp('state/vuex/state').loggedIn + sideEffect ({ data, contractID, hash, meta, innerSigningContractID, height }, { state }) { + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) const memberID = data.memberID || innerSigningContractID if (!state?.members?.[memberID]) { return } - if (memberID === loggedIn.identityContractID) { + const identityContractID = sbp('state/vuex/state').loggedIn.identityContractID + + if (memberID === identityContractID) { sbp('gi.actions/identity/kv/initChatRoomUnreadMessages', { contractID, messageHash: hash, createdHeight: height }) // subscribe to founder's IdentityContract & everyone else's - const profileIds = Object.keys(state.members).filter((id) => - id !== loggedIn.identityContractID && !rootGetters.ourContactProfilesById[id] - ) - sbp('chelonia/contract/sync', profileIds).catch((e) => { + const profileIds = Object.keys(state.members) + sbp('chelonia/contract/retain', profileIds).catch((e) => { console.error('Error while syncing other members\' contracts at chatroom join', e) }) } else { - if (!rootGetters.ourContactProfilesById[memberID]) { - sbp('chelonia/contract/sync', memberID).catch((e) => { - console.error(`Error while syncing new memberID's contract ${memberID}`, e) - }) - } + sbp('chelonia/contract/retain', memberID).catch((e) => { + console.error(`Error while syncing new memberID's contract ${memberID}`, e) + }) } }).catch((e) => { console.error('[gi.contracts/chatroom/join/sideEffect] Error at sideEffect', e?.message || e) @@ -322,9 +300,8 @@ sbp('chelonia/defineContract', { })) }, sideEffect ({ data, hash, contractID, meta, innerSigningContractID }) { - const rootState = sbp('state/vuex/state') const memberID = data.memberID || innerSigningContractID - const itsMe = memberID === rootState.loggedIn.identityContractID + const itsMe = memberID === sbp('state/vuex/state').loggedIn.identityContractID // NOTE: we don't add this 'if' statement in the queuedInvocation // because these should not be running while rejoining @@ -332,8 +309,8 @@ sbp('chelonia/defineContract', { leaveChatRoom(contractID) } - sbp('chelonia/queueInvocation', contractID, () => { - const state = rootState[contractID] + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) if (!state || !!state.members?.[data.memberID]) { return } @@ -360,7 +337,7 @@ sbp('chelonia/defineContract', { Vue.delete(state.members, memberID) } }, - sideEffect ({ meta, contractID }) { + sideEffect ({ contractID }, { state }) { // NOTE: make sure *not* to await on this, since that can cause // a potential deadlock. See same warning in sideEffect for // 'gi.contracts/group/removeMember' @@ -388,7 +365,7 @@ sbp('chelonia/defineContract', { Vue.delete(existingMsg, 'pending') } }, - sideEffect ({ contractID, hash, height, meta, data, innerSigningContractID }, { state, getters }) { + async sideEffect ({ contractID, hash, height, meta, data, innerSigningContractID }, { state }) { const me = sbp('state/vuex/state').loggedIn.identityContractID if (me === innerSigningContractID && data.type !== MESSAGE_TYPES.INTERACTIVE) { @@ -399,15 +376,15 @@ sbp('chelonia/defineContract', { const isMentionedMe = data.type === MESSAGE_TYPES.TEXT && (newMessage.text.includes(mentions.me) || newMessage.text.includes(mentions.all)) - messageReceivePostEffect({ + await messageReceivePostEffect({ contractID, messageHash: newMessage.hash, height: newMessage.height, text: newMessage.text, - isDMOrMention: isMentionedMe || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE, + isDMOrMention: isMentionedMe || state.settings.type === CHATROOM_TYPES.DIRECT_MESSAGE, messageType: data.type, memberID: innerSigningContractID, - chatRoomName: getters.chatRoomAttributes.name + chatRoomName: state.settings.name }) } }, @@ -437,20 +414,20 @@ sbp('chelonia/defineContract', { } }) }, - sideEffect ({ contractID, data, innerSigningContractID }, { getters }) { - const rootState = sbp('state/vuex/state') - const me = rootState.loggedIn.identityContractID - if (me === innerSigningContractID || getters.chatRoomAttributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { + async sideEffect ({ contractID, hash, meta, data, innerSigningContractID }, { state }) { + const me = sbp('state/vuex/state').loggedIn.identityContractID + if (me === innerSigningContractID || state.settings.type === CHATROOM_TYPES.DIRECT_MESSAGE) { return } + // TODO: This can't be a root getter when running in a SW const isAlreadyAdded = !!sbp('state/vuex/getters') .chatRoomUnreadMessages(contractID).find(m => m.messageHash === data.hash) const mentions = makeMentionFromUserID(me) const isMentionedMe = data.text.includes(mentions.me) || data.text.includes(mentions.all) if (!isAlreadyAdded) { - messageReceivePostEffect({ + await messageReceivePostEffect({ contractID, messageHash: data.hash, /* @@ -464,7 +441,7 @@ sbp('chelonia/defineContract', { isDMOrMention: isMentionedMe, messageType: MESSAGE_TYPES.TEXT, memberID: innerSigningContractID, - chatRoomName: getters.chatRoomAttributes.name + chatRoomName: state.settings.name }) } else if (!isMentionedMe) { sbp('gi.actions/identity/kv/removeChatRoomUnreadMessage', { contractID, messageHash: data.hash }) @@ -485,6 +462,7 @@ sbp('chelonia/defineContract', { if (state.attributes.type === CHATROOM_TYPES.DIRECT_MESSAGE) { throw new TypeError(L('Only the person who sent the message can delete it.')) } else { + // TODO: This can't be a root getter when running in a SW const groupID = sbp('state/vuex/getters').groupIdFromChatRoomId(contractID) if (sbp('state/vuex/state')[groupID]?.groupOwnerID !== innerSigningContractID) { throw new TypeError(L('Only the group creator and the person who sent the message can delete it.')) @@ -764,6 +742,13 @@ sbp('chelonia/defineContract', { } }, methods: { + 'gi.contracts/chatroom/_cleanup': ({ contractID, state }) => { + if (state) { + sbp('chelonia/contract/release', Object.keys(state.members)).catch(e => { + console.error(`[gi.contracts/chatroom/_cleanup] Error releasing chatroom members for ${contractID}`, Object.keys(state.members), e) + }) + } + }, 'gi.contracts/chatroom/rotateKeys': (contractID, state) => { if (!state._volatile) Vue.set(state, '_volatile', Object.create(null)) if (!state._volatile.pendingKeyRevocations) Vue.set(state._volatile, 'pendingKeyRevocations', Object.create(null)) diff --git a/frontend/model/contracts/group.js b/frontend/model/contracts/group.js index e005646702..5f99d81f3f 100644 --- a/frontend/model/contracts/group.js +++ b/frontend/model/contracts/group.js @@ -24,6 +24,7 @@ import { inviteType, chatRoomAttributesType } from './shared/types.js' import { arrayOf, objectOf, objectMaybeOf, optional, string, number, boolean, object, unionOf, tupleOf, actionRequireInnerSignature } from '~/frontend/model/contracts/misc/flowTyper.js' import { findKeyIdByName, findForeignKeysByContractID } from '~/shared/domains/chelonia/utils.js' import { REMOVE_NOTIFICATION } from '~/frontend/model/notifications/mutationKeys.js' +import { DELETED_CHATROOM, LEFT_CHATROOM, JOINED_CHATROOM, JOINED_GROUP } from '@utils/events.js' function vueFetchInitKV (obj: Object, key: string, initialValue: any): any { let value = obj[key] @@ -34,11 +35,12 @@ function vueFetchInitKV (obj: Object, key: string, initialValue: any): any { return value } -function initGroupProfile (joinedDate: string, joinedHeight: number) { +function initGroupProfile (joinedDate: string, joinedHeight: number, reference: string) { return { globalUsername: '', // TODO: this? e.g. groupincome:greg / namecoin:bob / ens:alice joinedDate, joinedHeight, + reference, nonMonetaryContributions: [], status: PROFILE_STATUS.ACTIVE, departedDate: null, @@ -302,7 +304,7 @@ const removeGroupChatroomProfile = (state, chatRoomID, member) => { ) } -const leaveChatRoomAction = (state, chatRoomID, memberID, actorID, leavingGroup) => { +const leaveChatRoomAction = async (groupID, state, chatRoomID, memberID, actorID, leavingGroup) => { const sendingData = leavingGroup || actorID !== memberID ? { memberID } : {} @@ -319,8 +321,8 @@ const leaveChatRoomAction = (state, chatRoomID, memberID, actorID, leavingGroup) // unconditionally in this situation, which should be a key in the // chatroom (either the CSK or the groupKey) if (leavingGroup) { - const encryptionKeyId = sbp('chelonia/contract/currentKeyIdByName', state, 'cek', true) - const signingKeyId = sbp('chelonia/contract/currentKeyIdByName', state, 'csk', true) + const encryptionKeyId = await sbp('chelonia/contract/currentKeyIdByName', state, 'cek', true) + const signingKeyId = await sbp('chelonia/contract/currentKeyIdByName', state, 'csk', true) // If we don't have a CSK, it is because we've already been removed. // Proceeding would cause an error @@ -342,21 +344,10 @@ const leaveChatRoomAction = (state, chatRoomID, memberID, actorID, leavingGroup) sbp('gi.actions/chatroom/leave', { contractID: chatRoomID, data: sendingData, - ...extraParams, - hooks: { - onprocessed: () => { - const rootState = sbp('state/vuex/state') - if (memberID === rootState.loggedIn.identityContractID) { - // NOTE: since the gi.contracts/chatroom/leave/sideEffect appends invocation in the queue - // the chatroom contract should be released after the queued invocation - // would be fully executed - sbp('chelonia/queueInvocation', chatRoomID, () => { - sbp('chelonia/contract/release', chatRoomID).catch(e => { - console.error(`[leaveChatRoomAction] Error releasing chatroom ${chatRoomID}`, e) - }) - }) - } - } + ...extraParams + }).then(() => { + if (memberID === sbp('state/vuex/state').loggedIn.identityContractID) { + sbp('okTurtles.events/emit', LEFT_CHATROOM, { identityContractID: memberID, groupContractID: groupID, chatRoomID }) } }).catch((e) => { if ( @@ -376,14 +367,21 @@ const leaveChatRoomAction = (state, chatRoomID, memberID, actorID, leavingGroup) }).catch((e) => { console.warn('[gi.contracts/group] Error sending chatroom leave action', e) }) + + if (memberID === sbp('state/vuex/state').loggedIn.identityContractID) { + sbp('chelonia/contract/release', chatRoomID).catch(e => { + console.error(`[leaveChatRoomAction] Error releasing chatroom ${chatRoomID}`, e) + }) + } } -const leaveAllChatRoomsUponLeaving = (state, memberID, actorID) => { +const leaveAllChatRoomsUponLeaving = (groupID, state, memberID, actorID) => { const chatRooms = state.chatRooms return Promise.all(Object.keys(chatRooms) .filter(cID => chatRooms[cID].members?.[memberID]?.status === PROFILE_STATUS.REMOVED) .map((chatRoomID) => leaveChatRoomAction( + groupID, state, chatRoomID, memberID, @@ -402,6 +400,7 @@ export const actionRequireActiveMember = (next: Function): Function => (data, pr } export const GIGroupAlreadyJoinedError: typeof Error = ChelErrorGenerator('GIGroupAlreadyJoinedError') +export const GIGroupNotJoinedError: typeof Error = ChelErrorGenerator('GIGroupNotJoinedError') sbp('chelonia/defineContract', { name: 'gi.contracts/group', @@ -727,16 +726,16 @@ sbp('chelonia/defineContract', { }, sideEffect ({ contractID }, { state }) { if (!state.generalChatRoomId) { - // create a 'General' chatroom contract - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - if (!rootState[contractID] || rootState[contractID].generalChatRoomId) return + // create a 'General' chatroom contract + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) + if (!state || state.generalChatRoomId) return const CSKid = findKeyIdByName(state, 'csk') const CEKid = findKeyIdByName(state, 'cek') // create a 'General' chatroom contract - return sbp('gi.actions/group/addChatRoom', { + sbp('gi.actions/group/addChatRoom', { contractID, data: { attributes: { @@ -751,6 +750,8 @@ sbp('chelonia/defineContract', { // The #General chatroom does not have an inner signature as it's part // of the group creation process innerSigningContractID: null + }).catch((e) => { + console.error(`[gi.contracts/group/sideEffect] Error creating #General chatroom for ${contractID} (unable to send action)`, e) }) }).catch((e) => { console.error(`[gi.contracts/group/sideEffect] Error creating #General chatroom for ${contractID}`, e) @@ -1067,7 +1068,7 @@ sbp('chelonia/defineContract', { const isGroupCreator = innerSigningContractID === getters.currentGroupOwnerID if (!state.profiles[memberToRemove]) { - throw new TypeError(L('Not part of the group.')) + throw new GIGroupNotJoinedError(L('Not part of the group.')) } if (membersCount === 1) { throw new TypeError(L('Cannot remove the last member.')) @@ -1118,12 +1119,12 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/inviteAccept': { - validate: actionRequireInnerSignature(() => {}), + validate: actionRequireInnerSignature(objectOf({ reference: string })), process ({ data, meta, height, innerSigningContractID }, { state }) { if (state.profiles[innerSigningContractID]?.status === PROFILE_STATUS.ACTIVE) { throw new Error(`[gi.contracts/group/inviteAccept] Existing members can't accept invites: ${innerSigningContractID}`) } - Vue.set(state.profiles, innerSigningContractID, initGroupProfile(meta.createdDate, height)) + Vue.set(state.profiles, innerSigningContractID, initGroupProfile(meta.createdDate, height, data.reference)) // If we're triggered by handleEvent in state.js (and not latestContractState) // then the asynchronous sideEffect function will get called next // and we will subscribe to this new user's identity contract @@ -1137,9 +1138,7 @@ sbp('chelonia/defineContract', { const { loggedIn } = sbp('state/vuex/state') sbp('chelonia/queueInvocation', contractID, async () => { - const rootState = sbp('state/vuex/state') - const rootGetters = sbp('state/vuex/getters') - const state = rootState[contractID] + const state = await sbp('chelonia/contract/state', contractID) if (!state) { console.info(`[gi.contracts/group/inviteAccept] Contract ${contractID} has been removed`) @@ -1168,15 +1167,7 @@ sbp('chelonia/defineContract', { if (state.chatRooms[generalChatRoomId]?.members?.[userID]?.status !== PROFILE_STATUS.ACTIVE) { sbp('gi.actions/group/joinChatRoom', { contractID, - data: { chatRoomID: generalChatRoomId }, - hooks: { - onprocessed: () => { - sbp('state/vuex/commit', 'setCurrentChatRoomId', { - groupID: contractID, - chatRoomID: generalChatRoomId - }) - } - } + data: { chatRoomID: generalChatRoomId } }).catch((e) => { // If already joined, ignore this error if (e?.name === 'GIErrorUIRuntimeError' && e.cause?.name === 'GIGroupAlreadyJoinedError') return @@ -1201,18 +1192,13 @@ sbp('chelonia/defineContract', { // subscribe to founder's IdentityContract & everyone else's const profileIds = Object.keys(profiles) - .filter((id) => id !== userID && !rootGetters.ourContactProfilesById[id]) if (profileIds.length !== 0) { sbp('chelonia/contract/retain', profileIds).catch((e) => { console.error('Error while syncing other members\' contracts at inviteAccept', e) }) } - // If we don't have a current group ID, select the group we've just joined - if (!rootState.currentGroupId) { - sbp('state/vuex/commit', 'setCurrentGroupId', contractID) - sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) - } + sbp('okTurtles.events/emit', JOINED_GROUP, { identityContractID: userID, groupContractID: contractID }) } else { // we're an existing member of the group getting notified that a // new member has joined, so subscribe to their identity contract @@ -1418,7 +1404,7 @@ sbp('chelonia/defineContract', { Vue.set(state, 'generalChatRoomId', data.chatRoomID) } }, - sideEffect ({ contractID }, { state }) { + sideEffect ({ contractID, data }, { state }) { if (Object.keys(state.chatRooms).length === 1) { // NOTE: only general chatroom exists, meaning group has just been created sbp('state/vuex/commit', 'setCurrentChatRoomId', { @@ -1426,6 +1412,25 @@ sbp('chelonia/defineContract', { chatRoomID: state.generalChatRoomId }) } + // If it's the #General chatroom being added, add ourselves to it + if (data.chatRoomID === state.generalChatRoomId) { + sbp('chelonia/queueInvocation', contractID, () => { + const { identityContractID } = sbp('state/vuex/state').loggedIn + if ( + state.profiles?.[identityContractID]?.status === PROFILE_STATUS.ACTIVE && + state.chatRooms?.[contractID]?.members[identityContractID]?.status !== PROFILE_STATUS.ACTIVE + ) { + sbp('gi.actions/group/joinChatRoom', { + contractID, + data: { + chatRoomID: data.chatRoomID + } + }).catch(e => { + console.error('Unable to add ourselves to the #General chatroom', e) + }) + } + }) + } } }, 'gi.contracts/group/deleteChatRoom': { @@ -1438,6 +1443,15 @@ sbp('chelonia/defineContract', { }), process ({ data }, { state }) { Vue.delete(state.chatRooms, data.chatRoomID) + }, + sideEffect ({ data, contractID, innerSigningContractID }) { + sbp('okTurtles.events/emit', DELETED_CHATROOM, { groupContractID: contractID, chatRoomID: data.chatRoomID }) + const { identityContractID } = sbp('state/vuex/state').loggedIn + if (identityContractID === innerSigningContractID) { + sbp('gi.actions/chatroom/delete', { contractID: data.chatRoomID, data: {} }).catch(e => { + console.log(`Error sending chatroom removal action for ${data.chatRoomID}`, e) + }) + } } }, 'gi.contracts/group/leaveChatRoom': { @@ -1456,13 +1470,12 @@ sbp('chelonia/defineContract', { removeGroupChatroomProfile(state, data.chatRoomID, memberID) }, sideEffect ({ data, contractID, innerSigningContractID }, { state }) { - const rootState = sbp('state/vuex/state') const memberID = data.memberID || innerSigningContractID - if (innerSigningContractID === rootState.loggedIn.identityContractID) { - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - if (rootState[contractID]?.profiles?.[innerSigningContractID]?.status === PROFILE_STATUS.ACTIVE) { - return leaveChatRoomAction(state, data.chatRoomID, memberID, innerSigningContractID) + if (innerSigningContractID === sbp('state/vuex/state').loggedIn.identityContractID) { + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) + if (state?.profiles?.[innerSigningContractID]?.status === PROFILE_STATUS.ACTIVE) { + return leaveChatRoomAction(contractID, state, data.chatRoomID, memberID, innerSigningContractID) } }).catch((e) => { console.error(`[gi.contracts/group/leaveChatRoom/sideEffect] Error for ${contractID}`, { contractID, data, error: e }) @@ -1503,23 +1516,22 @@ sbp('chelonia/defineContract', { Vue.set(state.chatRooms[chatRoomID].members, memberID, { status: PROFILE_STATUS.ACTIVE }) }, sideEffect ({ data, contractID, innerSigningContractID }) { - const rootState = sbp('state/vuex/state') const memberID = data.memberID || innerSigningContractID // If we added someone to the chatroom (including ourselves), we issue // the relevant action to the chatroom contract - if (innerSigningContractID === rootState.loggedIn.identityContractID) { + if (innerSigningContractID === sbp('state/vuex/state').loggedIn.identityContractID) { sbp('chelonia/queueInvocation', contractID, () => sbp('gi.contracts/group/joinGroupChatrooms', contractID, data.chatRoomID, memberID)).catch((e) => { console.warn(`[gi.contracts/group/joinChatRoom/sideEffect] Error adding member to group chatroom for ${contractID}`, { e, data }) }) - } else if (memberID === rootState.loggedIn.identityContractID) { + } else if (memberID === sbp('state/vuex/state').loggedIn.identityContractID) { // If we were the ones added to the chatroom, we sync the chatroom. // This is an `else` block because joinGroupChatrooms already calls // sync - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) - if (rootState[contractID]?.chatRooms[data.chatRoomID]?.members[memberID]?.status === PROFILE_STATUS.ACTIVE) { + if (state?.chatRooms[data.chatRoomID]?.members[memberID]?.status === PROFILE_STATUS.ACTIVE) { // If we were added by someone else, we might sync the chatroom // contract before the corresponding `/join` action is issued. // If we were previously a member of the chatroom, we would have @@ -1538,6 +1550,10 @@ sbp('chelonia/defineContract', { } }) } + + if (memberID === sbp('state/vuex/state').loggedIn.identityContractID) { + sbp('okTurtles.events/emit', JOINED_CHATROOM, { identityContractID: memberID, groupContractID: contractID, chatRoomID: data.chatRoomID }) + } } }, 'gi.contracts/group/renameChatRoom': { @@ -1562,19 +1578,19 @@ sbp('chelonia/defineContract', { validate: actionRequireActiveMember(optional), process ({ meta }, { state, getters }) { const period = getters.periodStampGivenDate(meta.createdDate) - const current = getters.groupSettings?.distributionDate + const current = state.settings?.distributionDate if (current !== period) { // right before updating to the new distribution period, make sure to update various payment-related group streaks. updateGroupStreaks({ state, getters }) - getters.groupSettings.distributionDate = period + state.settings.distributionDate = period } } }, ...((process.env.NODE_ENV === 'development' || process.env.CI) && { 'gi.contracts/group/forceDistributionDate': { validate: optional, - process ({ meta }, { getters }) { - getters.groupSettings.distributionDate = dateToPeriodStamp(meta.createdDate) + process ({ meta }, { state }) { + state.settings.distributionDate = dateToPeriodStamp(meta.createdDate) } }, 'gi.contracts/group/malformedMutation': { @@ -1610,9 +1626,14 @@ sbp('chelonia/defineContract', { // // IMPORTANT: they MUST begin with the name of the contract. methods: { - 'gi.contracts/group/_cleanup': ({ contractID }) => { + 'gi.contracts/group/_cleanup': ({ contractID, state }) => { // NOTE: should remove archived data from IndexedStorage // regarding the current group (proposals, payments) + const possiblyUselessContractIDs = Object.keys(state.profiles || {}) + sbp('chelonia/contract/release', possiblyUselessContractIDs).catch(e => + console.error('[gi.contracts/group/leaveGroup] Error calling release on all members', e) + ) + Promise.all([ () => sbp('gi.contracts/group/removeArchivedProposals', contractID), () => sbp('gi.contracts/group/removeArchivedPayments', contractID)] @@ -1731,7 +1752,8 @@ sbp('chelonia/defineContract', { // 1) automatically switch that user to a 'pledging' member with 0 contribution, // 2) pop out the prompt message notifying them of this automatic change, // 3) and send 'MINCOME_CHANGED' notification. - const myProfile = sbp('state/vuex/getters').ourGroupProfile + const identityContractID = sbp('state/vuex/state').loggedIn.identityContractID + const myProfile = sbp('chelonia/rootState')[contractID].profiles[identityContractID] const { fromAmount, toAmount } = data if (isActionOlderThanUser(contractID, height, myProfile) && myProfile.incomeDetailsType) { @@ -1778,9 +1800,8 @@ sbp('chelonia/defineContract', { } }, 'gi.contracts/group/joinGroupChatrooms': async function (contractID, chatRoomID, memberID) { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] - const actorID = rootState.loggedIn.identityContractID + const state = await sbp('chelonia/contract/state', contractID) + const actorID = sbp('state/vuex/state').loggedIn.identityContractID if (state?.profiles?.[actorID]?.status !== PROFILE_STATUS.ACTIVE || state?.profiles?.[memberID]?.status !== PROFILE_STATUS.ACTIVE || @@ -1808,7 +1829,7 @@ sbp('chelonia/defineContract', { // we could have left afterwards. await sbp('chelonia/contract/retain', chatRoomID, actorID !== memberID ? { ephemeral: true } : {}) - if (!sbp('chelonia/contract/hasKeysToPerformOperation', chatRoomID, 'gi.contracts/chatroom/join')) { + if (!await sbp('chelonia/contract/hasKeysToPerformOperation', chatRoomID, 'gi.contracts/chatroom/join')) { throw new Error(`Missing keys to join chatroom ${chatRoomID}`) } @@ -1821,6 +1842,8 @@ sbp('chelonia/defineContract', { contractID: chatRoomID, data: actorID === memberID ? {} : { memberID }, encryptionKeyId + }).then(() => { + sbp('okTurtles.events/emit', JOINED_CHATROOM, { identityContractID: memberID, groupContractID: sbp('state/vuex/state').currentGroupId, chatRoomID }) }).catch(e => { if (e.name === 'GIErrorUIRuntimeError' && e.cause?.name === 'GIChatroomAlreadyMemberError') { return @@ -1836,11 +1859,9 @@ sbp('chelonia/defineContract', { }, // eslint-disable-next-line require-await 'gi.contracts/group/leaveGroup': async ({ data, meta, contractID, height, getters, innerSigningContractID, proposalHash }) => { - const rootState = sbp('state/vuex/state') - const rootGetters = sbp('state/vuex/getters') - const state = rootState[contractID] - const { identityContractID } = rootState.loggedIn + const { identityContractID } = sbp('state/vuex/state').loggedIn const memberID = data.memberID || innerSigningContractID + const state = await sbp('chelonia/contract/state', contractID) if (!state) { console.info(`[gi.contracts/group/leaveGroup] for ${contractID}: contract has been removed`) @@ -1854,7 +1875,8 @@ sbp('chelonia/defineContract', { if (memberID === identityContractID) { // NOTE: remove all notifications whose scope is in this group - for (const notification of rootGetters.notificationsByGroup(contractID)) { + // TODO: FIND ANOTHER WAY OF DOING THIS WITHOUT ROOTGETTERS + for (const notification of sbp('state/vuex/getters').notificationsByGroup(contractID)) { sbp('state/vuex/commit', REMOVE_NOTIFICATION, notification) } @@ -1870,14 +1892,14 @@ sbp('chelonia/defineContract', { // the new keys or not. // First, we check if there are no pending key requests for us - const areWeRejoining = () => { - const pendingKeyShares = sbp('chelonia/contract/waitingForKeyShareTo', state, identityContractID) + const areWeRejoining = async () => { + const pendingKeyShares = await sbp('chelonia/contract/waitingForKeyShareTo', state, identityContractID) if (pendingKeyShares) { console.info('[gi.contracts/group/leaveGroup] Not removing group contract because it has a pending key share for ourselves', contractID) return true } // Now, let's see if we had a key request that's been answered - const sentKeyShares = sbp('chelonia/contract/successfulKeySharesByContractID', state, identityContractID) + const sentKeyShares = await sbp('chelonia/contract/successfulKeySharesByContractID', state, identityContractID) // We received a key share after the last time we left if (sentKeyShares?.[identityContractID]?.[0].height > state.profiles[memberID].departedHeight) { console.info('[gi.contracts/group/leaveGroup] Not removing group contract because it has shared keys with ourselves after we left', contractID) @@ -1886,7 +1908,7 @@ sbp('chelonia/defineContract', { return false } - if (areWeRejoining()) { + if (await areWeRejoining()) { console.info('[gi.contracts/group/leaveGroup] aborting as we\'re rejoining', contractID) // Previously we called `gi.actions/group/join` here, but it doesn't // seem necessary @@ -1894,20 +1916,16 @@ sbp('chelonia/defineContract', { } } - leaveAllChatRoomsUponLeaving(state, memberID, innerSigningContractID).catch((e) => { + leaveAllChatRoomsUponLeaving(contractID, state, memberID, innerSigningContractID).catch((e) => { console.warn('[gi.contracts/group/leaveGroup]: Error while leaving all chatrooms', e) }) if (memberID === identityContractID) { - const possiblyUselessContractIDs = Object.keys(state.profiles || {}).filter(cID => cID !== identityContractID) - sbp('chelonia/contract/release', possiblyUselessContractIDs).catch(e => - console.error('[gi.contracts/group/leaveGroup] Error calling release on all members', e) - ) - sbp('gi.actions/identity/leaveGroup', { contractID: identityContractID, data: { - groupContractID: contractID + groupContractID: contractID, + reference: state.profiles[identityContractID].reference } }).catch(e => { console.warn(`[gi.contracts/group/leaveGroup] ${e.name} thrown by gi.contracts/identity/leaveGroup ${identityContractID} for ${contractID}:`, e) @@ -1944,9 +1962,9 @@ sbp('chelonia/defineContract', { // TODO - #850 verify open proposals and see if they need some re-adjustment. }, - 'gi.contracts/group/rotateKeys': (contractID) => { - const rootState = sbp('state/vuex/state') - const pendingKeyRevocations = rootState[contractID]?._volatile?.pendingKeyRevocations + 'gi.contracts/group/rotateKeys': async (contractID) => { + const state = await sbp('chelonia/contract/state', contractID) + const pendingKeyRevocations = state?._volatile?.pendingKeyRevocations if (!pendingKeyRevocations || Object.keys(pendingKeyRevocations).length === 0) { // Don't rotate keys for removed contracts return diff --git a/frontend/model/contracts/identity.js b/frontend/model/contracts/identity.js index f7bcd27dc8..96587b81e0 100644 --- a/frontend/model/contracts/identity.js +++ b/frontend/model/contracts/identity.js @@ -1,9 +1,13 @@ 'use strict' +import { L, Vue } from '@common/common.js' import sbp from '@sbp/sbp' -import { Vue, L } from '@common/common.js' +import { arrayOf, boolean, object, objectMaybeOf, objectOf, optional, string, unionOf } from '~/frontend/model/contracts/misc/flowTyper.js' +import { LEFT_GROUP } from '~/frontend/utils/events.js' +import { Secret } from '~/shared/domains/chelonia/Secret.js' +import { findForeignKeysByContractID, findKeyIdByName } from '~/shared/domains/chelonia/utils.js' +import { IDENTITY_USERNAME_MAX_CHARS } from './shared/constants.js' import { has, merge } from './shared/giLodash.js' -import { objectOf, objectMaybeOf, arrayOf, string, object, boolean, optional, unionOf } from '~/frontend/model/contracts/misc/flowTyper.js' import { allowedUsernameCharacters, noConsecutiveHyphensOrUnderscores, @@ -11,9 +15,6 @@ import { noLeadingOrTrailingUnderscore, noUppercase } from './shared/validators.js' -import { findKeyIdByName, findForeignKeysByContractID } from '~/shared/domains/chelonia/utils.js' - -import { IDENTITY_USERNAME_MAX_CHARS } from './shared/constants.js' const attributesType = objectMaybeOf({ username: string, @@ -58,12 +59,12 @@ const checkUsernameConsistency = async (contractID: string, username: string) => // If there was a mismatch, wait until the contract is finished processing // (because the username could have been updated), and if the situation // persists, warn the user - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - if (!has(rootState, contractID)) return + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) + if (!state) return - const username = rootState[contractID].attributes.username - if (sbp('namespace/lookupCached', username) !== contractID) { + const username = state[contractID].attributes.username + if (await sbp('namespace/lookupCached', username) !== contractID) { sbp('gi.notifications/emit', 'WARNING', { contractID, message: L('Unable to confirm that the username {username} belongs to this identity contract', { username }) @@ -74,17 +75,7 @@ const checkUsernameConsistency = async (contractID: string, username: string) => sbp('chelonia/defineContract', { name: 'gi.contracts/identity', - getters: { - currentIdentityState (state) { - return state - }, - loginState (state, getters) { - return getters.currentIdentityState.loginState - }, - ourDirectMessages (state, getters) { - return getters.currentIdentityState.chatRooms || {} - } - }, + getters: {}, actions: { 'gi.contracts/identity': { validate: (data) => { @@ -174,10 +165,10 @@ sbp('chelonia/defineContract', { validate: objectOf({ contractID: string }), - process ({ data }, { state, getters }) { + process ({ data }, { state }) { // NOTE: this method is always created by another const { contractID } = data - if (getters.ourDirectMessages[contractID]) { + if (state.chatRooms[contractID]) { throw new TypeError(L('Already joined direct message.')) } @@ -185,8 +176,8 @@ sbp('chelonia/defineContract', { visible: true }) }, - sideEffect ({ data }, { getters }) { - if (getters.ourDirectMessages[data.contractID].visible) { + sideEffect ({ data }, { state }) { + if (state.chatRooms[data.contractID].visible) { sbp('chelonia/contract/retain', data.contractID).catch((e) => { console.error('[gi.contracts/identity/createDirectMessage/sideEffect] Error calling retain', e) }) @@ -199,29 +190,28 @@ sbp('chelonia/defineContract', { inviteSecret: string, creatorID: optional(boolean) }), - process ({ hash, data }, { state }) { + async process ({ hash, data }, { state }) { const { groupContractID, inviteSecret } = data if (has(state.groups, groupContractID)) { throw new Error(`Cannot join already joined group ${groupContractID}`) } - const inviteSecretId = sbp('chelonia/crypto/keyId', () => inviteSecret) + const inviteSecretId = await sbp('chelonia/crypto/keyId', new Secret(inviteSecret)) Vue.set(state.groups, groupContractID, { hash, inviteSecretId }) }, - sideEffect ({ hash, data, contractID }, { state }) { + async sideEffect ({ hash, data, contractID }, { state }) { const { groupContractID, inviteSecret } = data - sbp('chelonia/storeSecretKeys', () => [{ + await sbp('chelonia/storeSecretKeys', new Secret([{ key: inviteSecret, transient: true - }]) + }])) - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) // If we've logged out, return - if (!state || contractID !== rootState.loggedIn.identityContractID) { + if (!state || contractID !== sbp('state/vuex/state').loggedIn.identityContractID) { return } @@ -230,7 +220,7 @@ sbp('chelonia/defineContract', { return } - const inviteSecretId = sbp('chelonia/crypto/keyId', () => inviteSecret) + const inviteSecretId = sbp('chelonia/crypto/keyId', new Secret(inviteSecret)) // If the hash doesn't match (could happen after re-joining), return if (state.groups[groupContractID].hash !== hash) { @@ -238,7 +228,7 @@ sbp('chelonia/defineContract', { } return inviteSecretId - }).then((inviteSecretId) => { + }).then(async (inviteSecretId) => { // Calling 'gi.actions/group/join' here _after_ queueInvoication // and not inside of it. // This is because 'gi.actions/group/join' might (depending on @@ -254,6 +244,7 @@ sbp('chelonia/defineContract', { console.error('[gi.contracts/identity/joinGroup/sideEffect] Error calling retain', e) }) + console.error('@@@GROUP CONTRACT WILL CALL /join', { userID: contractID, contractID: data.groupContractID, hash, inviteSecretId }) sbp('gi.actions/group/join', { originatingContractID: contractID, originatingContractName: 'gi.contracts/identity', @@ -261,8 +252,8 @@ sbp('chelonia/defineContract', { contractName: 'gi.contracts/group', reference: hash, signingKeyId: inviteSecretId, - innerSigningKeyId: sbp('chelonia/contract/currentKeyIdByName', state, 'csk'), - encryptionKeyId: sbp('chelonia/contract/currentKeyIdByName', state, 'cek') + innerSigningKeyId: await sbp('chelonia/contract/currentKeyIdByName', state, 'csk'), + encryptionKeyId: await sbp('chelonia/contract/currentKeyIdByName', state, 'cek') }).catch(e => { console.warn(`[gi.contracts/identity/joinGroup/sideEffect] Error sending gi.actions/group/join action for group ${data.groupContractID}`, e) }) @@ -273,7 +264,8 @@ sbp('chelonia/defineContract', { }, 'gi.contracts/identity/leaveGroup': { validate: objectOf({ - groupContractID: string + groupContractID: string, + reference: string }), process ({ data }, { state }) { const { groupContractID } = data @@ -282,15 +274,18 @@ sbp('chelonia/defineContract', { throw new Error(`Cannot leave group which hasn't been joined ${groupContractID}`) } + if (state.groups[groupContractID].hash !== data.reference) { + throw new Error(`Cannot leave group ${groupContractID} because the reference hash does not match the latest`) + } + Vue.delete(state.groups, groupContractID) }, sideEffect ({ data, contractID }) { - sbp('chelonia/queueInvocation', contractID, () => { - const rootState = sbp('state/vuex/state') - const state = rootState[contractID] + sbp('chelonia/queueInvocation', contractID, async () => { + const state = await sbp('chelonia/contract/state', contractID) // If we've logged out, return - if (!state || contractID !== rootState.loggedIn.identityContractID) { + if (!state || contractID !== sbp('state/vuex/state').loggedIn.identityContractID) { return } @@ -301,63 +296,36 @@ sbp('chelonia/defineContract', { return } - if (has(rootState.contracts, groupContractID)) { - sbp('gi.actions/group/removeOurselves', { - contractID: groupContractID - }).catch(e => { - console.warn(`[gi.contracts/identity/leaveGroup/sideEffect] Error removing ourselves from group contract ${data.groupContractID}`, e) - }) - } + sbp('gi.actions/group/removeOurselves', { + contractID: groupContractID + }).catch(e => { + if (e?.name === 'GIErrorUIRuntimeError' && e.cause?.name === 'GIGroupNotJoinedError') return + console.warn(`[gi.contracts/identity/leaveGroup/sideEffect] Error removing ourselves from group contract ${data.groupContractID}`, e) + }) sbp('chelonia/contract/release', data.groupContractID).catch((e) => { console.error('[gi.contracts/identity/leaveGroup/sideEffect] Error calling release', e) }) - // grab the groupID of any group that we're a part of - if (!rootState.currentGroupId || rootState.currentGroupId === data.groupContractID) { - const groupIdToSwitch = Object.keys(state.groups) - .filter(cID => - cID !== data.groupContractID - ).sort(cID => - // prefer successfully joined groups - rootState[cID]?.profiles?.[contractID] ? -1 : 1 - )[0] || null - sbp('state/vuex/commit', 'setCurrentChatRoomId', {}) - sbp('state/vuex/commit', 'setCurrentGroupId', groupIdToSwitch) - } - // Remove last logged in information - Vue.delete(rootState.lastLoggedIn, contractID) - - // this looks crazy, but doing this was necessary to fix a race condition in the - // group-member-removal Cypress tests where due to the ordering of asynchronous events - // we were getting the same latestHash upon re-logging in for test "user2 rejoins groupA". - // We add it to the same queue as '/release' above gets run on so that it is run after - // contractID is removed. See also comments in 'gi.actions/identity/login'. - try { - const router = sbp('controller/router') - const switchFrom = router.currentRoute.path - const switchTo = rootState.currentGroupId ? '/dashboard' : '/' - if (switchFrom !== '/join' && switchFrom !== switchTo) { - router.push({ path: switchTo }).catch((e) => console.error('Error switching groups', e)) - } - } catch (e) { - console.error(`[gi.contracts/identity/leaveGroup/sideEffect]: ${e.name} thrown updating routes:`, e) + if (sbp('state/vuex/state').lastLoggedIn?.[contractID]) { + Vue.delete(sbp('state/vuex/state').lastLoggedIn, contractID) } sbp('gi.contracts/identity/revokeGroupKeyAndRotateOurPEK', contractID, state, data.groupContractID) + sbp('okTurtles.events/emit', LEFT_GROUP, { identityContractID: contractID, groupContractID: data.groupContractID }) }).catch(e => { console.error(`[gi.contracts/identity/leaveGroup/sideEffect] Error leaving group ${data.groupContractID}`, e) }) } }, 'gi.contracts/identity/setDirectMessageVisibility': { - validate: (data, { getters }) => { + validate: (data, { state }) => { objectOf({ contractID: string, visible: boolean })(data) - if (!getters.ourDirectMessages[data.contractID]) { + if (!state.chatRooms[data.contractID]) { throw new TypeError(L('Not existing direct message.')) } }, diff --git a/frontend/model/contracts/shared/functions.js b/frontend/model/contracts/shared/functions.js index eb251a5d82..0ed13181ff 100644 --- a/frontend/model/contracts/shared/functions.js +++ b/frontend/model/contracts/shared/functions.js @@ -162,11 +162,6 @@ export function leaveChatRoom (contractID: string) { if (sbp('chelonia/contract/isSyncing', contractID, { firstSync: true })) { return } - const rootState = sbp('state/vuex/state') - const rootGetters = sbp('state/vuex/getters') - if (contractID === rootGetters.currentChatRoomId) { - sbp('state/vuex/commit', 'setCurrentChatRoomId', { groupID: rootState.currentGroupId }) - } sbp('gi.actions/identity/kv/deleteChatRoomUnreadMessages', { contractID }) sbp('state/vuex/commit', 'deleteChatRoomScrollPosition', { chatRoomID: contractID }) diff --git a/frontend/model/contracts/shared/voting/proposals.js b/frontend/model/contracts/shared/voting/proposals.js index ffd89abf60..314e1df58e 100644 --- a/frontend/model/contracts/shared/voting/proposals.js +++ b/frontend/model/contracts/shared/voting/proposals.js @@ -63,9 +63,7 @@ export const proposalSettingsType: any = objectOf({ }) // returns true IF a single YES vote is required to pass the proposal -export function oneVoteToPass (proposalHash: string): boolean { - const rootState = sbp('state/vuex/state') - const state = rootState[rootState.currentGroupId] +export function oneVoteToPass (state: Object, proposalHash: string): boolean { const proposal = state.proposals[proposalHash] const votes = Object.assign({}, proposal.votes) const currentResult = rules[proposal.data.votingRule](state, proposal.data.proposalType, votes) diff --git a/frontend/model/database.js b/frontend/model/database.js index 0c4d9f1987..a0c9b3c875 100644 --- a/frontend/model/database.js +++ b/frontend/model/database.js @@ -1,9 +1,144 @@ 'use strict' import sbp from '@sbp/sbp' -import localforage from 'localforage' import { CURVE25519XSALSA20POLY1305, decrypt, encrypt, generateSalt, keyId, keygen, serializeKey } from '../../shared/domains/chelonia/crypto.js' +const _instances = [] +// Localforage-like API for IndexedDB +const localforage = { + async ready () { + await Promise.all(_instances).then(() => {}) + }, + createInstance ({ name, storeName }: { name: string, storeName: string }) { + // Open the IndexedDB database + const db = new Promise((resolve, reject) => { + if (name.includes('-') || storeName.includes('-')) { + reject(new Error('Unsupported characters in name: -')) + return + } + const request = self.indexedDB.open(name + '--' + storeName) + + // Create the object store if it doesn't exist + request.onupgradeneeded = (event) => { + const db = event.target.result + db.createObjectStore(storeName) + } + + request.onsuccess = (event) => { + const db = event.target.result + resolve(db) + } + + request.onerror = (error) => { + reject(error) + } + + request.onblocked = (event) => { + reject(new Error('DB is blocked')) + } + }) + + _instances.push(db) + + return { + clear () { + return db.then(db => { + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.clear() + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e) + } + }) + }) + }, + getItem (key: string) { + return db.then(db => { + const transaction = db.transaction([storeName], 'readonly') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.get(key) + return new Promise((resolve, reject) => { + request.onsuccess = (event) => { + resolve(event.target.result) + } + request.onerror = (e) => { + reject(e) + } + }) + }) + }, + removeItem (key: string) { + return db.then(db => { + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.delete(key) + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e.target.error) + } + }) + }) + }, + setItem (key: string, value: any) { + return db.then(db => { + const transaction = db.transaction([storeName], 'readwrite') + const objectStore = transaction.objectStore(storeName) + const request = objectStore.put(value, key) + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve() + } + request.onerror = (e) => { + reject(e.target.error) + } + }) + }) + } + } + } +} + +export const generateEncryptionParams = async (stateKeyEncryptionKeyFn: (stateEncryptionKeyId: string, salt: string) => Promise<*>): Promise<{ encryptionParams: { + stateEncryptionKeyId: string, + salt: string, + encryptedStateEncryptionKey: string +}, stateEncryptionKeyP: string }> => { + // Create the necessary keys + // First, we generate the state encryption key + const stateEncryptionKey = keygen(CURVE25519XSALSA20POLY1305) + const stateEncryptionKeyId = keyId(stateEncryptionKey) + const stateEncryptionKeyS = serializeKey(stateEncryptionKey, true) + const stateEncryptionKeyP = serializeKey(stateEncryptionKey, false) + + // Once we have the state encryption key, we generate a salt + const salt = generateSalt() + + // We use the salt, the state encryption key ID and the password to + // derive a key to encrypt the state encryption key + // This key is not stored anywhere, but is used for reconstructing + // the state on a fresh session + const stateKeyEncryptionKey = await stateKeyEncryptionKeyFn(stateEncryptionKeyId, salt) + + // Once everything is place, encrypt the state encryption key + const encryptedStateEncryptionKey = encrypt(stateKeyEncryptionKey, stateEncryptionKeyS, stateEncryptionKeyId) + + return { + encryptionParams: { + stateEncryptionKeyId, + salt, + encryptedStateEncryptionKey + }, + stateEncryptionKeyP + } +} + if (process.env.LIGHTWEIGHT_CLIENT !== 'true') { const log = localforage.createInstance({ name: 'Group Income', @@ -34,14 +169,22 @@ class EmptyValue extends Error {} export const SETTING_CURRENT_USER = '@settings/currentUser' sbp('sbp/selectors/register', { + 'gi.db/ready': function () { + return localforage.ready() + }, 'gi.db/settings/save': function (user: string, value: any): Promise<*> { - return appSettings.setItem(user, value) + // Items in the DB have a prefix to disambiguate their type. + // 'u' means unencrypted data + // 'e' means encrypted data + // 'k' means that it's a cryptographic key (used for encrypted data) + // This allows us to store encrypted and unencrypted states for the same user + return appSettings.setItem('u' + user, value) }, 'gi.db/settings/load': function (user: string): Promise { - return appSettings.getItem(user) + return appSettings.getItem('u' + user) }, 'gi.db/settings/delete': function (user: string): Promise { - return appSettings.removeItem(user) + return appSettings.removeItem('u' + user) }, 'gi.db/settings/saveEncrypted': async function (user: string, value: any, encryptionParams: any): Promise<*> { const { @@ -50,45 +193,38 @@ sbp('sbp/selectors/register', { encryptedStateEncryptionKey } = encryptionParams // Fetch the session encryption key - const stateEncryptionKeyS = await appSettings.getItem(stateEncryptionKeyId) - if (!stateEncryptionKeyS) throw new Error(`Unable to retrieve the key corresponding to key ID ${stateEncryptionKeyId}`) + const stateEncryptionKeyP = await appSettings.getItem('k' + stateEncryptionKeyId) + if (!stateEncryptionKeyP) throw new Error(`Unable to retrieve the key corresponding to key ID ${stateEncryptionKeyId}`) // Encrypt the current state - const encryptedState = encrypt(stateEncryptionKeyS, JSON.stringify(value), user) - // Save the three fields of the encrypted state: + const encryptedState = encrypt(stateEncryptionKeyP, JSON.stringify(value), user) + // Save the four fields of the encrypted state. We use base64 encoding to + // allow saving any incoming data. // (1) stateEncryptionKeyId // (2) salt // (3) encryptedStateEncryptionKey (used for recovery when re-logging in) // (4) encryptedState - return appSettings.setItem(user, `${stateEncryptionKeyId}.${salt}.${encryptedStateEncryptionKey}.${encryptedState}}`) + return appSettings.setItem('e' + user, `${btoa(stateEncryptionKeyId)}.${btoa(salt)}.${btoa(encryptedStateEncryptionKey)}.${btoa(encryptedState)}`) }, - 'gi.db/settings/loadEncrypted': function (user: string, stateKeyEncryptionKeyFn): Promise<*> { - return appSettings.getItem(user).then(async (encryptedValue) => { + 'gi.db/settings/loadEncrypted': function (user: string, stateKeyEncryptionKeyFn: (stateEncryptionKeyId: string, salt: string) => Promise<*>): Promise<*> { + return appSettings.getItem('e' + user).then(async (encryptedValue) => { if (!encryptedValue || typeof encryptedValue !== 'string') { throw new EmptyValue(`Unable to retrive state for ${user || ''}`) } // Split the encrypted state into its constituent parts - const [stateEncryptionKeyId, salt, encryptedStateEncryptionKey, data] = encryptedValue.split('.') - - // If the state encryption key is in appSettings, retrieve it - let stateEncryptionKeyS = await appSettings.getItem(stateEncryptionKeyId) - - // If the state encryption key wasn't in appSettings but we have a state - // state key encryption derivation function (stateKeyEncryptionKeyFn), - // call it - if (!stateEncryptionKeyS && stateKeyEncryptionKeyFn) { - // Derive a temporary key from the password to decrypt the state - // encryption key - const stateKeyEncryptionKey = await stateKeyEncryptionKeyFn(stateEncryptionKeyId, salt) - - // Decrypt the state encryption key - stateEncryptionKeyS = decrypt(stateKeyEncryptionKey, encryptedStateEncryptionKey, stateEncryptionKeyId) - - // Compute the key ID of the decrypted key and verify that it holds - // the expected value - const stateEncryptionKeyIdActual = keyId(stateEncryptionKeyS) - if (stateEncryptionKeyIdActual !== stateEncryptionKeyId) { - throw new Error(`Invalid state key ID: expected ${stateEncryptionKeyId} but got ${stateEncryptionKeyIdActual}`) - } + const [stateEncryptionKeyId, salt, encryptedStateEncryptionKey, data] = encryptedValue.split('.').map(x => atob(x)) + + // Derive a temporary key from the password to decrypt the state + // encryption key + const stateKeyEncryptionKey = await stateKeyEncryptionKeyFn(stateEncryptionKeyId, salt) + + // Decrypt the state encryption key + const stateEncryptionKeyS = decrypt(stateKeyEncryptionKey, encryptedStateEncryptionKey, stateEncryptionKeyId) + + // Compute the key ID of the decrypted key and verify that it holds + // the expected value + const stateEncryptionKeyIdActual = keyId(stateEncryptionKeyS) + if (stateEncryptionKeyIdActual !== stateEncryptionKeyId) { + throw new Error(`Invalid state key ID: expected ${stateEncryptionKeyId} but got ${stateEncryptionKeyIdActual}`) } // Now, attempt to decrypt the state @@ -96,7 +232,7 @@ sbp('sbp/selectors/register', { // Saving the state encryption key in appSettings is necessary for // functionality such as refreshing the page to work - await appSettings.setItem(stateEncryptionKeyId, stateEncryptionKeyS) + await appSettings.setItem('k' + stateEncryptionKeyId, stateEncryptionKeyS) return { encryptionParams: { @@ -116,39 +252,22 @@ sbp('sbp/selectors/register', { console.warn('Error while retrieving local state', e) } - // Create the necessary keys - // First, we generate the state encryption key - const stateEncryptionKey = keygen(CURVE25519XSALSA20POLY1305) - const stateEncryptionKeyId = keyId(stateEncryptionKey) - const stateEncryptionKeyS = serializeKey(stateEncryptionKey, true) - - // Once we have the state encryption key, we generate a salt - const salt = generateSalt() - - // We use the salt, the state encryption key ID and the password to - // derive a key to encrypt the state encryption key - // This key is not stored anywhere, but is used for reconstructing - // the state on a fresh session - const stateKeyEncryptionKey = await stateKeyEncryptionKeyFn(stateEncryptionKeyId, salt) - - // Once everything is place, encrypt the state encryption key - const encryptedStateEncryptionKey = encrypt(stateKeyEncryptionKey, stateEncryptionKeyS, stateEncryptionKeyId) + const { encryptionParams, stateEncryptionKeyP } = await generateEncryptionParams(stateKeyEncryptionKeyFn) // Save the state encryption key to local storage - await appSettings.setItem(stateEncryptionKeyId, stateEncryptionKeyS) + await appSettings.setItem('k' + encryptionParams.stateEncryptionKeyId, stateEncryptionKeyP) return { - encryptionParams: { - stateEncryptionKeyId, - salt, - encryptedStateEncryptionKey - }, + encryptionParams, value: null } }) }, 'gi.db/settings/deleteStateEncryptionKey': function ({ stateEncryptionKeyId }): Promise { - return appSettings.removeItem(stateEncryptionKeyId) + return appSettings.removeItem('k' + stateEncryptionKeyId) + }, + 'gi.db/settings/deleteEncrypted': function (user: string): Promise { + return appSettings.removeItem('e' + user) } }) diff --git a/frontend/model/notifications/templates.js b/frontend/model/notifications/templates.js index 983a9f9639..f51348738f 100644 --- a/frontend/model/notifications/templates.js +++ b/frontend/model/notifications/templates.js @@ -1,4 +1,4 @@ -import { GIMessage } from '~/shared/domains/chelonia/chelonia.js' +import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js' import type { NewProposalType, NotificationTemplate diff --git a/frontend/model/state.js b/frontend/model/state.js index eacf279b1a..368da6b68d 100644 --- a/frontend/model/state.js +++ b/frontend/model/state.js @@ -27,7 +27,7 @@ const initialState = { loggedIn: false, // false | { username: string, identityContractID: string } namespaceLookups: Object.create(null), // { [username]: sbp('namespace/lookup') } periodicNotificationAlreadyFiredMap: {}, // { notificationKey: boolean }, - contractSiginingKeys: Object.create(null), + contractSigningKeys: Object.create(null), lastLoggedIn: {}, // Group last logged in information preferences: {} } @@ -64,6 +64,7 @@ sbp('sbp/selectors/register', { state.notifications = notificationModule.state() state.settings = settingsModule.state() state.chatroom = chatroomModule.state() + state.idleVue = { isIdle: false } store.replaceState(state) }, 'state/vuex/replace': (state) => store.replaceState(state), @@ -81,14 +82,20 @@ sbp('sbp/selectors/register', { state.preferences = {} } }, - 'state/vuex/save': async function () { - const state = store.state + 'state/vuex/save': async function (encrypted: ?boolean, state: ?Object) { + state = state || store.state // IMPORTANT! DO NOT CALL VUEX commit() in here in any way shape or form! // Doing so will cause an infinite loop because of store.subscribe below! - if (state.loggedIn) { - const { identityContractID, encryptionParams } = state.loggedIn - state.notifications.items = applyStorageRules(state.notifications.items || []) + if (!state.loggedIn) { + return + } + + const { identityContractID, encryptionParams } = state.loggedIn + state.notifications.items = applyStorageRules(state.notifications.items || []) + if (encrypted) { await sbp('gi.db/settings/saveEncrypted', identityContractID, state, encryptionParams) + } else { + await sbp('gi.db/settings/save', identityContractID, state) } } }) @@ -99,9 +106,16 @@ const mutations = { login (state, user) { state.loggedIn = user }, - setCurrentGroupId (state, currentGroupId) { + // isNewlyCreated will force a redirect to /pending-approval + setCurrentGroupId (state, { contractID: currentGroupId, isNewlyCreated }) { // TODO: unsubscribe from events for all members who are not in this group + console.error('@@@@SETTING CURRENT GROUP ID (ACTUAL)', currentGroupId, isNewlyCreated) Vue.set(state, 'currentGroupId', currentGroupId) + if (!currentGroupId) { + sbp('controller/router').push({ path: '/' }).catch(() => {}) + } else if (isNewlyCreated) { + sbp('controller/router').push({ path: '/pending-approval' }).catch(() => {}) + } }, setPreferences (state, value) { Vue.set(state, 'preferences', value) @@ -146,8 +160,8 @@ const getters = { currentIdentityState (state) { return (state.loggedIn && state[state.loggedIn.identityContractID]) || {} }, - ourUsername (state) { - return state.loggedIn && state.loggedIn.username + ourUsername (state, getters) { + return state.loggedIn && getters.usernameFromID(state.loggedIn.identityContractID) }, ourPreferences (state) { return state.preferences @@ -545,6 +559,9 @@ const getters = { const nameB = getters.ourContactProfilesByUsername[usernameB].displayName || usernameB return nameA.normalize().toUpperCase() > nameB.normalize().toUpperCase() ? 1 : -1 }) + }, + ourDirectMessages (state, getters) { + return getters.currentIdentityState.chatRooms || {} } } @@ -600,8 +617,8 @@ const omitGetters = { 'gi.contracts/identity': ['currentIdentityState'], 'gi.contracts/chatroom': ['currentChatRoomState'] } -sbp('okTurtles.events/on', CONTRACT_REGISTERED, (contract) => { - const { contracts: { manifests } } = sbp('chelonia/config') +sbp('okTurtles.events/on', CONTRACT_REGISTERED, async (contract) => { + const { contracts: { manifests } } = await sbp('chelonia/config') // check to make sure we're only loading the getters for the version of the contract // that this build of GI was compiled with if (manifests[contract.name] === contract.manifest) { diff --git a/frontend/utils/events.js b/frontend/utils/events.js index 51c3c70a3c..a488cdb1d5 100644 --- a/frontend/utils/events.js +++ b/frontend/utils/events.js @@ -7,10 +7,17 @@ export const LOGIN = 'login' export const LOGIN_ERROR = 'login-error' +export const LOGIN_COMPLETE = 'login-complete' export const LOGOUT = 'logout' +export const ACCEPTED_GROUP = 'accepted-group' export const SWITCH_GROUP = 'switch-group' export const JOINED_GROUP = 'joined-group' +export const LEFT_GROUP = 'left-group' + +export const JOINED_CHATROOM = 'joined-chatroom' +export const LEFT_CHATROOM = 'left-chatroom' +export const DELETED_CHATROOM = 'deleted-chatroom' export const REPLACED_STATE = 'replaced-state' @@ -41,4 +48,7 @@ export const CHATROOM_EVENTS = 'chatroom-events' export const CHATROOM_USER_TYPING = 'chatroom-user-typing' export const CHATROOM_USER_STOP_TYPING = 'chatroom-user-stop-typing' +export const NAMESPACE_REGISTRATION = 'namespace-registration' +export const SHELTER_EVENT_HANDLED = 'shelter-event-handled' + export const KV_QUEUE = 'kv-queue' diff --git a/frontend/views/components/Avatar.vue b/frontend/views/components/Avatar.vue index 1196b8576e..3247159b71 100644 --- a/frontend/views/components/Avatar.vue +++ b/frontend/views/components/Avatar.vue @@ -15,6 +15,7 @@