Skip to content

Commit

Permalink
Merge branch 'master' into 2460-improvements-to-password-salt-record
Browse files Browse the repository at this point in the history
  • Loading branch information
corrideat committed Jan 15, 2025
2 parents d6034ce + 307baf6 commit 0dba5fb
Show file tree
Hide file tree
Showing 27 changed files with 1,314 additions and 533 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,12 @@ grunt dev --tunnel

> [!IMPORTANT]
> This service (localtunnel) doesn't seem to work anymore.
> Instead please try [localhost.run](https://localhost.run/) or [serveo](https://serveo.net/) instead, e.g.:
> Instead please try [ngrok](https://ngrok.com/) (recommended) or [localhost.run](https://localhost.run/) or [serveo](https://serveo.net/) instead, e.g.:
> ```
> $ grunt dev
> # then, in another terminal:
> $ ngrok http http://localhost:8000
> # or:
> $ ssh -R 80:localhost:8000 [email protected]
> ```
Expand Down
19 changes: 18 additions & 1 deletion backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
createPushErrorResponse,
createServer
} from './pubsub.js'
import { addChannelToSubscription, deleteChannelFromSubscription, pushServerActionhandlers, subscriptionInfoWrapper } from './push.js'
import { addChannelToSubscription, deleteChannelFromSubscription, postEvent, pushServerActionhandlers, subscriptionInfoWrapper } from './push.js'
import { GIMessage } from '~/shared/domains/chelonia/GIMessage.js'
import type { SubMessage, UnsubMessage } from '~/shared/pubsub.js'

Expand Down Expand Up @@ -378,3 +378,20 @@ sbp('okTurtles.data/set', PUBSUB_INSTANCE, createServer(hapi.listener, {
console.info('Backend server running at:', hapi.info.uri)
sbp('okTurtles.events/emit', SERVER_RUNNING, hapi)
})()

// Recurring task to send messages to push clients (for periodic notifications)
setInterval(() => {
const pubsub = sbp('okTurtles.data/get', PUBSUB_INSTANCE)
// Notification text
const notification = JSON.stringify({ type: 'recurring' })
// Find push subscriptions that do _not_ have a WS open. This means clients
// that are 'asleep' and that might be woken up by the push event
Object.values(pubsub.pushSubscriptions || {})
.filter((pushSubscription: Object) => pushSubscription.sockets.size === 0)
.forEach((pushSubscription: Object) => {
postEvent(pushSubscription, notification).catch((e) => {
console.warn(e, 'Error sending recurring message to web push client', pushSubscription.id)
})
})
// Repeat every 12 hours
}, 12 * 60 * 60 * 1000)
180 changes: 102 additions & 78 deletions frontend/controller/actions/group.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use strict'

import { GIErrorUIRuntimeError, L, LError, LTags } from '@common/common.js'
import { GIErrorUIRuntimeError, L, LError } from '@common/common.js'
import {
CHATROOM_PRIVACY_LEVEL,
INVITE_INITIAL_CREATOR,
Expand Down Expand Up @@ -31,16 +31,14 @@ import {
JOINED_GROUP,
JOINED_CHATROOM,
LEFT_GROUP,
LOGOUT,
OPEN_MODAL,
REPLACE_MODAL
LOGOUT
} from '@utils/events.js'
import { imageUpload } from '@utils/image.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 { 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'
Expand All @@ -56,6 +54,13 @@ sbp('okTurtles.events/on', LEFT_GROUP, ({ identityContractID, groupContractID })
sbp('gi.notifications/remove', notificationHashes)
})

const JOINED_FAILED_KEY = 'gi.actions/group/join/failed'
let reattemptTimeoutId

sbp('okTurtles.events/on', CHELONIA_RESET, () => {
sbp('okTurtles.data/delete', JOINED_FAILED_KEY)
})

export default (sbp('sbp/selectors/register', {
'gi.actions/group/create': async function ({
data: {
Expand Down Expand Up @@ -451,46 +456,50 @@ export default (sbp('sbp/selectors/register', {
const userCSKdata = rootState[userID]._vm.authorizedKeys[userCSKid].data

try {
// Share our PEK with the group so that group members can see
// our name and profile information
PEKid && await sbp('gi.actions/out/shareVolatileKeys', {
contractID: params.contractID,
contractName: 'gi.contracts/group',
subjectContractID: userID,
keyIds: [PEKid]
})

const existingForeignKeys = await sbp('chelonia/contract/foreignKeysByContractID', params.contractID, userID)
// Check to avoid adding existing keys to the contract
if (!existingForeignKeys?.includes(userCSKid)) {
await sbp('chelonia/out/keyAdd', {
contractID: params.contractID,
contractName: 'gi.contracts/group',
data: [encryptedOutgoingData(params.contractID, CEKid, {
foreignKey: `shelter:${encodeURIComponent(userID)}?keyName=${encodeURIComponent('csk')}`,
id: userCSKid,
data: userCSKdata,
permissions: [GIMessage.OP_ACTION_ENCRYPTED + '#inner'],
allowedActions: '*',
purpose: ['sig'],
ringLevel: Number.MAX_SAFE_INTEGER,
name: `${userID}/${userCSKid}`
})],
signingKeyId: CSKid
})
}

// Send inviteAccept action to the group to add ourselves to the members list
await sbp('chelonia/contract/wait', params.contractID)
await sbp('gi.actions/group/inviteAccept', {
await sbp('chelonia/out/atomic', {
...omit(params, ['options', 'action', 'hooks', 'encryptionKeyId', 'signingKeyId']),
data: {
// The 'reference' value is used to help keep group joins
// updated. A matching value is required when leaving a group,
// which prevents us from accidentally leaving a group due to
// a previous leave action when re-joining
reference: rootState[userID].groups[params.contractID].hash
},
data: [
// Share our PEK with the group so that group members can see
// our name and profile information
PEKid && await sbp('gi.actions/out/shareVolatileKeys', {
contractID: params.contractID,
contractName: 'gi.contracts/group',
subjectContractID: userID,
keyIds: [PEKid],
returnInvocation: true
}),
// Check to avoid adding existing keys to the contract
!existingForeignKeys?.includes(userCSKid) && ['chelonia/out/keyAdd', {
contractID: params.contractID,
contractName: 'gi.contracts/group',
data: [encryptedOutgoingData(params.contractID, CEKid, {
foreignKey: `shelter:${encodeURIComponent(userID)}?keyName=${encodeURIComponent('csk')}`,
id: userCSKid,
data: userCSKdata,
permissions: [GIMessage.OP_ACTION_ENCRYPTED + '#inner'],
allowedActions: '*',
purpose: ['sig'],
ringLevel: Number.MAX_SAFE_INTEGER,
name: `${userID}/${userCSKid}`
})],
signingKeyId: CSKid
}],
// Send inviteAccept action to the group to add ourselves to the members list
await sbp('gi.actions/group/inviteAccept', {
...omit(params, ['options', 'action', 'hooks', 'encryptionKeyId', 'signingKeyId']),
data: {
// The 'reference' value is used to help keep group joins
// updated. A matching value is required when leaving a group,
// which prevents us from accidentally leaving a group due to
// a previous leave action when re-joining
reference: rootState[userID].groups[params.contractID].hash
},
returnInvocation: true
})
].filter(Boolean),
signingKeyId: CSKid,
hooks: {
prepublish: params.hooks?.prepublish,
postpublish: null
Expand Down Expand Up @@ -521,18 +530,42 @@ export default (sbp('sbp/selectors/register', {
}
} catch (e) {
console.error('gi.actions/group/join failed!', e)
sbp('gi.ui/prompt', {
heading: L('Failed to join the group'),
question: L('Error details:{br_}{err}', { err: e.message, ...LTags() }),
primaryButton: L('Close')
})
throw e
} finally {
// If we called join but it didn't result in any actions being sent, we
// may have left the group. In this case, we execute any pending /remove
// actions on the contract. This will have no side-effects if /remove on
// the group contract hasn't been called.
await sbp('chelonia/contract/release', retainedContracts, { ephemeral: true })
}
}).then(() => {
const map: Map<string, any> = sbp('okTurtles.data/get', JOINED_FAILED_KEY)
map?.delete(params.contractID)
}).catch(e => {
let map: Map<string, any> = sbp('okTurtles.data/get', JOINED_FAILED_KEY)
if (!map) {
map = new Map()
sbp('okTurtles.data/set', JOINED_FAILED_KEY, map)
}

map.set(params.contractID, params)

const scheduleReattempt = () => {
if (reattemptTimeoutId === undefined) {
reattemptTimeoutId = setTimeout(() => {
reattemptTimeoutId = undefined
if (map.size === 0) return

sbp('gi.actions/group/reattemptFailedJoins').catch(e => {
console.error('Error running reattemptFailedJoins', e)
scheduleReattempt()
})
}, 60e3)
}
}
scheduleReattempt()

throw e
})
},
'gi.actions/group/joinWithInviteSecret': async function (groupId: string, secret: string) {
Expand Down Expand Up @@ -571,6 +604,28 @@ export default (sbp('sbp/selectors/register', {
await sbp('chelonia/contract/release', groupId, { ephemeral: true })
}
},
'gi.actions/group/reattemptFailedJoins': async function () {
const ourGroups = sbp('state/vuex/getters').ourGroups
const map: Map<string, any> = sbp('okTurtles.data/get', JOINED_FAILED_KEY)
if (!map) return

return await Promise.allSettled([...map.entries()].map(([contractID, params]) => {
if (!ourGroups.includes(contractID)) {
map.delete(contractID)
return undefined
}

return sbp('gi.actions/group/join', params).catch(e => {
console.error('Error on group join re-attempt', params, e)

throw e
})
})).then((results) => {
if (results.some(result => result.status === 'rejected')) {
throw new Error('Error on group join re-attempt')
}
})
},
'gi.actions/group/shareNewKeys': (contractID: string, newKeys) => {
const rootState = sbp('chelonia/rootState')
const state = rootState[contractID]
Expand Down Expand Up @@ -911,37 +966,6 @@ export default (sbp('sbp/selectors/register', {
})
}
}),
'gi.actions/group/displayMincomeChangedPrompt': async function ({ data }: GIActionParams) {
const { withGroupCurrency } = sbp('state/vuex/getters')
const promptOptions = data.increased
? {
heading: L('Mincome changed'),
question: L('Do you make at least {amount} per month?', { amount: withGroupCurrency(data.amount) }),
primaryButton: data.memberType === 'pledging' ? L('No') : L('Yes'),
secondaryButton: data.memberType === 'pledging' ? L('Yes') : L('No')
}
: {
heading: L('Automatically switched to pledging {zero}', { zero: withGroupCurrency(0) }),
question: L('You now make more than the mincome. Would you like to increase your pledge?'),
primaryButton: L('Yes'),
secondaryButton: L('No')
}

const primaryButtonSelected = await sbp('gi.ui/prompt', promptOptions)
if (primaryButtonSelected) {
// NOTE: emtting 'REPLACE_MODAL' instead of 'OPEN_MODAL' here because 'Prompt' modal is open at this point (by 'gi.ui/prompt' action above).
sbp('okTurtles.events/emit', REPLACE_MODAL, 'IncomeDetails')
}
},
'gi.actions/group/checkAndSeeProposal': function ({ data }: GIActionParams) {
const openProposalIds = Object.keys(sbp('state/vuex/getters').currentGroupState.proposals || {})

if (openProposalIds.includes(data.proposalHash)) {
sbp('controller/router').push({ path: '/dashboard#proposals' })
} else {
sbp('okTurtles.events/emit', OPEN_MODAL, 'PropositionsAllModal', { targetProposal: data.proposalHash })
}
},
'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 () => {
Expand Down
10 changes: 7 additions & 3 deletions frontend/controller/actions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ sbp('sbp/selectors/register', {
contractID,
contractName,
subjectContractID,
keyIds
keyIds,
returnInvocation
}) => {
if (contractID === subjectContractID) {
return
Expand Down Expand Up @@ -74,12 +75,15 @@ sbp('sbp/selectors/register', {
}))
}

await sbp('chelonia/out/keyShare', {
const invocation = ['chelonia/out/keyShare', {
contractID,
contractName,
data: encryptedOutgoingData(contractID, CEKid, payload),
signingKeyId
})
}]
if (returnInvocation) return invocation

await sbp(...invocation)
} finally {
await sbp('chelonia/contract/release', contractID, { ephemeral: true })
}
Expand Down
43 changes: 43 additions & 0 deletions frontend/controller/app/group.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,48 @@ export default (sbp('sbp/selectors/register', {
} else {
sbp('okTurtles.events/emit', OPEN_MODAL, 'AddMembers')
}
},
'gi.app/group/checkAndSeeProposal': function ({ contractID, data }: GIActionParams) {
const rootGetters = sbp('state/vuex/getters')
const rootState = sbp('state/vuex/state')
if (rootState.currentGroupId !== contractID) {
sbp('state/vuex/commit', 'setCurrentGroupId', { contractID })
}

const openProposalIds = Object.keys(rootGetters.currentGroupState.proposals || {})

if (openProposalIds.includes(data.proposalHash)) {
sbp('controller/router').push({ path: '/dashboard#proposals' })
} else {
sbp('okTurtles.events/emit', OPEN_MODAL, 'PropositionsAllModal', { targetProposal: data.proposalHash })
}
},
'gi.app/group/displayMincomeChangedPrompt': async function ({ contractID, data }: GIActionParams) {
const rootGetters = sbp('state/vuex/getters')
const rootState = sbp('state/vuex/state')
if (rootState.currentGroupId !== contractID) {
sbp('state/vuex/commit', 'setCurrentGroupId', { contractID })
}

const { withGroupCurrency } = rootGetters
const promptOptions = data.increased
? {
heading: L('Mincome changed'),
question: L('Do you make at least {amount} per month?', { amount: withGroupCurrency(data.amount) }),
primaryButton: data.memberType === 'pledging' ? L('No') : L('Yes'),
secondaryButton: data.memberType === 'pledging' ? L('Yes') : L('No')
}
: {
heading: L('Automatically switched to pledging {zero}', { zero: withGroupCurrency(0) }),
question: L('You now make more than the mincome. Would you like to increase your pledge?'),
primaryButton: L('Yes'),
secondaryButton: L('No')
}

const primaryButtonSelected = await sbp('gi.ui/prompt', promptOptions)
if (primaryButtonSelected) {
// NOTE: emtting 'REPLACE_MODAL' instead of 'OPEN_MODAL' here because 'Prompt' modal is open at this point (by 'gi.ui/prompt' action above).
sbp('okTurtles.events/emit', REPLACE_MODAL, 'IncomeDetails')
}
}
}): string[])
Loading

0 comments on commit 0dba5fb

Please sign in to comment.