Skip to content

Commit

Permalink
Closes #2515
Browse files Browse the repository at this point in the history
  • Loading branch information
corrideat committed Jan 19, 2025
1 parent f981369 commit bf69192
Show file tree
Hide file tree
Showing 7 changed files with 3 additions and 263 deletions.
3 changes: 1 addition & 2 deletions frontend/controller/actions/chatroom.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,5 @@ export default (sbp('sbp/selectors/register', {
...encryptedAction('gi.actions/chatroom/delete', L('Failed to delete chat channel.')),
...encryptedAction('gi.actions/chatroom/voteOnPoll', L('Failed to vote on a poll.')),
...encryptedAction('gi.actions/chatroom/changeVoteOnPoll', L('Failed to change vote on a poll.')),
...encryptedAction('gi.actions/chatroom/closePoll', L('Failed to close a poll.')),
...encryptedAction('gi.actions/chatroom/upgradeFrom1.0.8', L('Failed to upgrade from version 1.0.8'))
...encryptedAction('gi.actions/chatroom/closePoll', L('Failed to close a poll.'))
}): string[])
67 changes: 0 additions & 67 deletions frontend/controller/actions/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
STATUS_EXPIRED,
STATUS_CANCELLED
} from '@model/contracts/shared/constants.js'
import { doesGroupAnyoneCanJoinNeedUpdating } from '@model/contracts/shared/functions.js'
import { merge, omit, randomIntFromRange } from '@model/contracts/shared/giLodash.js'
import { DAYS_MILLIS, addTimeToDate, dateToPeriodStamp } from '@model/contracts/shared/time.js'
import proposals, { oneVoteToPass, oneVoteToFail } from '@model/contracts/shared/voting/proposals.js'
Expand All @@ -39,7 +38,6 @@ 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 { CHELONIA_RESET, CONTRACT_HAS_RECEIVED_KEYS, EVENT_HANDLED } from '~/shared/domains/chelonia/events.js'
import { findKeyIdByName } from '~/shared/domains/chelonia/utils.js'
// Using relative path to crypto.js instead of ~-path to workaround some esbuild bug
import type { Key } from '../../../shared/domains/chelonia/crypto.js'
import { CURVE25519XSALSA20POLY1305, EDWARDS25519SHA512BATCH, keyId, keygen, serializeKey } from '../../../shared/domains/chelonia/crypto.js'
Expand Down Expand Up @@ -966,70 +964,6 @@ export default (sbp('sbp/selectors/register', {
})
}
}),
'gi.actions/group/fixAnyoneCanJoinLink': function ({ contractID }) {
// Queue ensures that the update happens as atomically as possible
return sbp('chelonia/queueInvocation', `${contractID}-FIX-ANYONE-CAN-JOIN`, async () => {
const now = await sbp('chelonia/time') * 1000
const state = await sbp('chelonia/contract/wait', contractID).then(() => sbp('chelonia/contract/state', contractID))

const quantity = doesGroupAnyoneCanJoinNeedUpdating(state)
if (!quantity) {
if (quantity === false) {
console.warn('[gi.actions/group/fixAnyoneCanJoinLink] Group has already been updated', contractID, MAX_GROUP_MEMBER_COUNT)
} else {
console.warn('[gi.actions/group/fixAnyoneCanJoinLink] Already used MAX_GROUP_MEMBER_COUNT invites for group', contractID, MAX_GROUP_MEMBER_COUNT)
}
return
}

const CEKid = findKeyIdByName(state, 'cek')
const CSKid = findKeyIdByName(state, 'csk')

if (!CEKid || !CSKid) {
throw new Error('Contract is missing a CEK or CSK')
}

const inviteKey = keygen(EDWARDS25519SHA512BATCH)
const inviteKeyId = keyId(inviteKey)
const inviteKeyP = serializeKey(inviteKey, false)
const inviteKeyS = encryptedOutgoingData(state, CEKid, serializeKey(inviteKey, true))

const ik = {
id: inviteKeyId,
name: '#inviteKey-' + inviteKeyId,
purpose: ['sig'],
ringLevel: Number.MAX_SAFE_INTEGER,
permissions: [GIMessage.OP_KEY_REQUEST],
meta: {
quantity,
...(INVITE_EXPIRES_IN_DAYS.ON_BOARDING && {
expires:
now + DAYS_MILLIS * INVITE_EXPIRES_IN_DAYS.ON_BOARDING
}),
private: {
content: inviteKeyS
}
},
data: inviteKeyP
}

// Replace all existing anyone-can-join invite links with the new one
const activeInvites = Object.keys(state.invites)
.filter(invite => state.invites[invite].creatorID === INVITE_INITIAL_CREATOR && !(state._vm.invites[invite].expires >= now))

await sbp('chelonia/out/keyAdd', {
contractName: 'gi.contracts/group',
contractID,
data: [ik],
signingKeyId: CSKid
})

await sbp('gi.actions/group/invite', { contractID, data: { inviteKeyId, creatorID: INVITE_INITIAL_CREATOR } })

// Revoke at the end
await Promise.all(activeInvites.map(inviteKeyId => sbp('gi.actions/group/inviteRevoke', { contractID, data: { inviteKeyId } })))
})
},
...encryptedAction('gi.actions/group/leaveChatRoom', L('Failed to leave chat channel.'), async (sendMessage, params) => {
const state = await sbp('chelonia/contract/state', params.contractID)
const memberID = params.data.memberID || sbp('state/vuex/state').loggedIn.identityContractID
Expand Down Expand Up @@ -1167,7 +1101,6 @@ export default (sbp('sbp/selectors/register', {
...encryptedAction('gi.actions/group/updateAllVotingRules', (params, e) => L('Failed to update voting rules. {codeError}', { codeError: e.message })),
...encryptedAction('gi.actions/group/updateDistributionDate', L('Failed to update group distribution date.')),
...encryptedAction('gi.actions/group/updateGroupInviteExpiry', L('Failed to update group invite expiry.')),
...encryptedAction('gi.actions/group/upgradeFrom1.0.7', L('Failed to upgrade from version 1.0.7')),
...((process.env.NODE_ENV === 'development' || process.env.CI) && {
...encryptedAction('gi.actions/group/forceDistributionDate', L('Failed to force distribution date.'))
})
Expand Down
18 changes: 1 addition & 17 deletions frontend/controller/serviceworkers/sw-primary.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,23 +215,7 @@ sbp('sbp/selectors/register', {

return computedGetters
}
})(),
// For compatibility for old contracts.
// TODO: This is to be deleted in a later release, once contracts are recreated.
'state/vuex/commit': (key, data) => {
switch (key) {
case 'deleteChatRoomScrollPosition':
case 'setChatRoomScrollPosition':
return sbp('okTurtles.events/emit', NEW_CHATROOM_UNREAD_POSITION, data)
// These are handled by events
case 'removeNotification':
return
case 'setCurrentChatRoomId':
return
}

throw new Error(`Invalid selector 'state/vuex/commit': Not allowed in SW. (key: ${key})`)
}
})()
})

const ourLocation = new URL(self.location)
Expand Down
10 changes: 0 additions & 10 deletions frontend/model/contracts/chatroom.js
Original file line number Diff line number Diff line change
Expand Up @@ -632,16 +632,6 @@ sbp('chelonia/defineContract', {
delete state.messages[msgIndex]['pinnedBy']
}
}
},
// Action meant to upgrade contracts missing adminIDs
'gi.contracts/chatroom/upgradeFrom1.0.8': {
validate: actionRequireInnerSignature(optional(string)),
process ({ data }, { state }) {
if (state.attributes.adminIDs) {
throw new Error('Upgrade can only be done once')
}
state.attributes.adminIDs = data ? [data] : []
}
}
},
methods: {
Expand Down
17 changes: 0 additions & 17 deletions frontend/model/contracts/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -1374,23 +1374,6 @@ sbp('chelonia/defineContract', {
}
}
},
'gi.contracts/group/upgradeFrom1.0.7': {
validate: actionRequireActiveMember(optional),
process ({ height }, { state }) {
let changed = false
Object.values(state.chatRooms).forEach((chatroom: Object) => {
Object.values(chatroom.members).forEach((member: Object) => {
if (member.status === PROFILE_STATUS.ACTIVE && member.joinedHeight == null) {
member.joinedHeight = height
changed = true
}
})
})
if (!changed) {
throw new Error('[gi.contracts/group/upgradeFrom1.0.7/process] Invalid or duplicate upgrade action')
}
}
},
...((process.env.NODE_ENV === 'development' || process.env.CI) && {
'gi.contracts/group/forceDistributionDate': {
validate: optional,
Expand Down
37 changes: 0 additions & 37 deletions frontend/model/contracts/shared/functions.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
import sbp from '@sbp/sbp'
import { L } from '@common/common.js'
import {
INVITE_INITIAL_CREATOR,
MAX_GROUP_MEMBER_COUNT,
MESSAGE_TYPES,
POLL_STATUS,
PROPOSAL_GROUP_SETTING_CHANGE,
Expand Down Expand Up @@ -314,38 +312,3 @@ export const referenceTally = (selector: string): Object => {
}
}
}

export const doesGroupAnyoneCanJoinNeedUpdating = (state: Object): number | false => {
const hasBeenUpdated = Object.keys(state.invites).some(inviteId => {
return (
// See if there's an 'anyone can join' link
state.invites[inviteId].creatorID === INVITE_INITIAL_CREATOR &&
// that doesn't expire
state._vm.invites[inviteId].expires == null
)
})
// If non-expiring 'anyone can join' links are found, the contract
// doesn't need to be updated
if (hasBeenUpdated) return false

// Add up all all used 'anyone can join' invite links
// Then, we take the difference and, if the number is less than
// MAX_GROUP_MEMBER_COUNT, we need to create a new invite.
const usedInvites = Object.keys(state.invites)
// First, we only want 'anyone can join invites'
.filter(invite => state.invites[invite].creatorID === INVITE_INITIAL_CREATOR)
// The reduce function adds the number of 'used' invites in an invite
// link across all existing (expired or not) invites
.reduce((acc, cv) => acc +
(
// For this, we take the difference between the `initialQuantity`
// and the 'available' invites (`quantity`, which represents how
// many invites are available, if the invite was still valid)
(state._vm.invites[cv].initialQuantity - state._vm.invites[cv].quantity) || 0
), 0
)

const quantity = Math.max(MAX_GROUP_MEMBER_COUNT - usedInvites, 0)

return quantity
}
114 changes: 1 addition & 113 deletions frontend/model/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@

import sbp from '@sbp/sbp'
import { EVENT_HANDLED, CONTRACT_REGISTERED } from '~/shared/domains/chelonia/events.js'
import { doesGroupAnyoneCanJoinNeedUpdating } from '@model/contracts/shared/functions.js'
import { LOGOUT } from '~/frontend/utils/events.js'
import Vue from 'vue'
import Vuex from 'vuex'
import { PROFILE_STATUS } from '@model/contracts/shared/constants.js'
import { cloneDeep, debounce } from '@model/contracts/shared/giLodash.js'
import { applyStorageRules } from '~/frontend/model/notifications/utils.js'
import getters from './getters.js'
Expand All @@ -22,6 +20,7 @@ import { CHELONIA_RESET, CONTRACTS_MODIFIED } from '../../shared/domains/cheloni

// Wrapper function for performing contract upgrades and migrations
// TODO: Consider moving this function into a different file
// eslint-disable-next-line no-unused-vars
const contractUpdate = (initialState: Object, updateFn: (state: Object, contractIDHints: ?string[]) => any, contractType: ?string) => {
// Wrapper for the update function. This performs a common check, namely that
// the contract is of a certain type, which helps return early
Expand Down Expand Up @@ -134,10 +133,6 @@ sbp('sbp/selectors/register', {
if (!state.preferences) {
state.preferences = {}
}
if (!state.reverseNamespaceLookups) {
// $FlowFixMe[incompatible-call]
Vue.set(state, 'reverseNamespaceLookups', Object.fromEntries(Object.entries(state.namespaceLookups).map(([k, v]: [string, string]) => [v, k])))
}
if (state.periodicNotificationAlreadyFiredMap) {
if (!state.periodicNotificationAlreadyFiredMap.alreadyFired) {
state.periodicNotificationAlreadyFiredMap.alreadyFired = Object.create(null)
Expand All @@ -146,113 +141,6 @@ sbp('sbp/selectors/register', {
state.periodicNotificationAlreadyFiredMap.lastRun = Object.create(null)
}
}
contractUpdate(state, (state: Object, contractIDHints: ?string[]) => {
// Upgrade from version 1.0.7 to a newer version
// The new group contract introduces a breaking change: the
// `state[groupID].chatRooms[chatRoomID].members[memberID].joinedHeight`
// attribute.
// This code checks if the attribute is missing, and if so, issues the
// corresponing upgrade action.
const ourIdentityContractId = state.loggedIn?.identityContractID
if (!ourIdentityContractId || !state[ourIdentityContractId]?.groups) return
Object.entries(state[ourIdentityContractId].groups)
.filter(([groupID, { hasLeft }]: [string, Object]) => {
return !hasLeft &&
state[groupID]?.chatRooms &&
(!Array.isArray(contractIDHints) || contractIDHints.includes(groupID))
})
.map(([groupID]) => {
// $FlowFixMe[incompatible-use]
const chatRooms = state[groupID].chatRooms
const needsUpgrade = ((Object.values(chatRooms): any): Object[])
.flatMap(({ members }): Object => Object.values(members))
.some(member =>
member.status === PROFILE_STATUS.ACTIVE && member.joinedHeight == null
)

return needsUpgrade ? groupID : null
})
.filter(Boolean)
.forEach((contractID) => {
if (!contractID) return
sbp('gi.actions/group/upgradeFrom1.0.7', { contractID }).catch(e => {
console.error('[state/vuex/postUpgradeVerification] Error during gi.actions/group/upgradeFrom1.0.7', contractID, e)
})
})
}, 'gi.contracts/group')
contractUpdate(state, (contractIDHints: ?string[]) => {
// Upgrade from version 1.0.8 to a newer version
// The new chatroom contracts have an admin IDs list
// This code checks if the attribute is missing, and if so, issues the
// corresponing upgrade action.
const needsUpgrade = (chatroomID) => {
// Restrict updates to recently added contracts
if (Array.isArray(contractIDHints) && !contractIDHints.includes(chatroomID)) return false
return !!state[chatroomID]?.attributes && !Array.isArray(state[chatroomID].attributes.adminIDs)
}

const upgradeAction = async (contractID: string, data?: Object) => {
try {
await sbp('gi.actions/chatroom/upgradeFrom1.0.8', { contractID, data })
} catch (e) {
// If the action failed because the upgrade has already happened, we
// can safely ignore the error
if (e.message?.includes('Upgrade can only be done once')) {
console.warn(`[state/vuex/postUpgradeVerification] Error during gi.actions/chatroom/upgradeFrom1.0.8 for ${contractID}:`, e)
return
}
console.error(`[state/vuex/postUpgradeVerification] Error during gi.actions/chatroom/upgradeFrom1.0.8 for ${contractID}:`, e)
}
}

const ourIdentityContractId = state.loggedIn?.identityContractID
if (!ourIdentityContractId || !state[ourIdentityContractId]) return
if (state[ourIdentityContractId].groups) {
// Group chatrooms
Object.entries(state[ourIdentityContractId].groups).map(([groupID, { hasLeft }]: [string, Object]) => {
if (hasLeft || !state[groupID]?.chatRooms || !state[groupID].groupOwnerID) return []
// $FlowFixMe[incompatible-use]
return Object.entries((state[groupID].chatRooms: { [string]: Object })).flatMap(([chatroomID, { members }]) => {
if (members[ourIdentityContractId]?.status === PROFILE_STATUS.ACTIVE && needsUpgrade(chatroomID)) {
return [chatroomID, state[groupID].groupOwnerID]
}
return []
})
}).forEach(([contractID, groupOwnerID]) => {
if (!contractID) return
upgradeAction(contractID, groupOwnerID)
})
}
if (state[ourIdentityContractId].chatRooms) {
// DM chatrooms
return Object.keys((state[ourIdentityContractId].chatRooms: { [string]: Object })).map((chatroomID) => {
if (state[chatroomID]?.members[ourIdentityContractId] && needsUpgrade(chatroomID)) {
return chatroomID
}
return false
}).forEach((contractID) => {
if (!contractID) return
upgradeAction(contractID)
})
}
}, 'gi.contracts/chatroom')
contractUpdate(state, (contractIDHints: ?string[]) => {
// Update expired invites
// If fewer than MAX_GROUP_MEMBER_COUNT 'anyone can join' have been used,
// create a new 'anyone can join' link up to MAX_GROUP_MEMBER_COUNT invites
const ourIdentityContractId = state.loggedIn?.identityContractID
if (!ourIdentityContractId || !state[ourIdentityContractId]?.groups) return
Object.entries(state[ourIdentityContractId].groups).map(([groupID, { hasLeft }]: [string, Object]) => {
const groupState = state[groupID]
if (hasLeft || !groupState?.invites) return undefined
// Restrict updates to recently added contracts
if (Array.isArray(contractIDHints) && !contractIDHints.includes(groupID)) return undefined
const needsUpdate = !!doesGroupAnyoneCanJoinNeedUpdating(groupState)
return needsUpdate ? groupID : undefined
}).filter(Boolean).forEach((contractID) => {
sbp('gi.actions/group/fixAnyoneCanJoinLink', { contractID }).catch(e => console.error(`[state/vuex/postUpgradeVerification] Error during gi.actions/group/fixAnyoneCanJoinLink for ${contractID}:`, e))
})
}, 'gi.contracts/group')
},
'state/vuex/save': (encrypted: ?boolean, state: ?Object) => {
return sbp('okTurtles.eventQueue/queueEvent', 'state/vuex/save', async function () {
Expand Down

0 comments on commit bf69192

Please sign in to comment.