Browse Source

Merge pull request #1297 from meriadec/feature/1235-rework-persistence

Rework persistence data layer
master
Gaëtan Renaudeau 7 years ago
committed by GitHub
parent
commit
088a21041b
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      .circleci/config.yml
  2. 1
      .eslintrc
  3. 6
      package.json
  4. 10
      src/actions/accounts.js
  5. 4
      src/analytics/segment.js
  6. 27
      src/components/IsUnlocked.js
  7. 33
      src/components/Onboarding/steps/SetPassword.js
  8. 52
      src/components/SettingsPage/DisablePasswordModal.js
  9. 58
      src/components/SettingsPage/PasswordButton.js
  10. 8
      src/components/SettingsPage/PasswordForm.js
  11. 19
      src/components/SettingsPage/PasswordModal.js
  12. 4
      src/components/SettingsPage/sections/Display.js
  13. 2
      src/components/SettingsPage/sections/Tools.js
  14. 4
      src/components/TopBar/index.js
  15. 19
      src/components/base/InputPassword/index.js
  16. 4
      src/config/errors.js
  17. 13
      src/helpers/db/db-storybook.js
  18. 104
      src/helpers/db/db.js
  19. 226
      src/helpers/db/db.spec.js
  20. 292
      src/helpers/db/index.js
  21. 8
      src/helpers/promise.js
  22. 6
      src/helpers/user.js
  23. 5
      src/logger/index.js
  24. 15
      src/main/app.js
  25. 9
      src/main/bridge.js
  26. 12
      src/middlewares/db.js
  27. 33
      src/migrations/index.js
  28. 53
      src/migrations/migrations.js
  29. 96
      src/migrations/migrations.spec.js
  30. BIN
      src/migrations/mocks/userdata_v1.0.5_mock-01.zip
  31. BIN
      src/migrations/mocks/userdata_v1.0.5_mock-02-encrypted-accounts.zip
  32. 6
      src/reducers/application.js
  33. 12
      src/reducers/settings.js
  34. 10
      src/renderer/events.js
  35. 22
      src/renderer/init.js
  36. 5
      src/sentry/browser.js
  37. 4
      yarn.lock

1
.circleci/config.yml

@ -21,4 +21,5 @@ jobs:
- run: yarn lint
- run: ./node_modules/.bin/prettier -l "{src,webpack,.storybook,static/i18n}/**/*.js"
- run: yarn flow --quiet
- run: yarn test
- run: yarn release

1
.eslintrc

@ -20,6 +20,7 @@
"ResizeObserver": false,
"jest": false,
"describe": false,
"beforeEach": false,
"test": false,
"it": false,
"expect": false,

6
package.json

@ -15,8 +15,9 @@
"compile": "bash ./scripts/compile.sh",
"lint": "eslint src webpack .storybook",
"flow": "flow",
"test": "jest",
"prettier": "prettier --write \"{src,webpack,.storybook}/**/*.{js,json}\"",
"ci": "yarn lint && yarn flow && yarn prettier",
"ci": "yarn lint && yarn flow && yarn prettier && yarn test",
"storybook": "NODE_ENV=development STORYBOOK_ENV=1 start-storybook -s ./static -p 4444",
"publish-storybook": "bash ./scripts/legacy/publish-storybook.sh",
"reset-files": "bash ./scripts/legacy/reset-files.sh"
@ -42,7 +43,6 @@
"async": "^2.6.1",
"axios": "^0.18.0",
"babel-runtime": "^6.26.0",
"bcryptjs": "^2.4.3",
"bignumber.js": "^7.2.1",
"bitcoinjs-lib": "^3.3.2",
"bs58": "^4.0.1",
@ -87,6 +87,7 @@
"redux-actions": "^2.4.0",
"redux-thunk": "^2.3.0",
"reselect": "^3.0.1",
"rimraf": "^2.6.2",
"ripple-binary-codec": "^0.1.13",
"ripple-bs58check": "^2.0.2",
"ripple-hashes": "^0.3.1",
@ -106,6 +107,7 @@
"winston": "^3.0.0",
"winston-daily-rotate-file": "^3.2.3",
"winston-transport": "^4.2.0",
"write-file-atomic": "^2.3.0",
"ws": "^5.1.1",
"zxcvbn": "^4.4.2"
},

10
src/actions/accounts.js

@ -16,14 +16,12 @@ export const removeAccount: RemoveAccount = payload => ({
payload,
})
export type FetchAccounts = () => *
export const fetchAccounts: FetchAccounts = () => {
db.init('accounts', []) // FIXME the "init" pattern to drop imo. a simple get()||[] is enough
const accounts = db.get('accounts')
return {
export const fetchAccounts = () => async (dispatch: *) => {
const accounts = await db.getKey('app', 'accounts', [])
return dispatch({
type: 'SET_ACCOUNTS',
payload: accounts,
}
})
}
export type UpdateAccountWithUpdater = (accountId: string, (Account) => Account) => *

4
src/analytics/segment.js

@ -52,9 +52,9 @@ const extraProperties = store => {
let storeInstance // is the redux store. it's also used as a flag to know if analytics is on or off.
export const start = (store: *) => {
export const start = async (store: *) => {
if (!user) return
const { id } = user()
const { id } = await user()
logger.analyticsStart(id)
storeInstance = store
const { analytics } = window

27
src/components/IsUnlocked.js

@ -1,6 +1,5 @@
// @flow
import bcrypt from 'bcryptjs'
import React, { Component } from 'react'
import { connect } from 'react-redux'
import { compose } from 'redux'
@ -8,13 +7,11 @@ import { remote } from 'electron'
import styled from 'styled-components'
import { translate } from 'react-i18next'
import type { SettingsState as Settings } from 'reducers/settings'
import type { T } from 'types/common'
import { i } from 'helpers/staticPath'
import IconTriangleWarning from 'icons/TriangleWarning'
import get from 'lodash/get'
import { setEncryptionKey } from 'helpers/db'
import db from 'helpers/db'
import hardReset from 'helpers/hardReset'
import { fetchAccounts } from 'actions/accounts'
@ -39,7 +36,6 @@ type Props = {
children: any,
fetchAccounts: Function,
isLocked: boolean,
settings: Settings,
t: T,
unlock: Function,
}
@ -52,7 +48,6 @@ type State = {
const mapStateToProps = state => ({
isLocked: isLocked(state),
settings: state.settings,
})
const mapDispatchToProps: Object = {
@ -112,18 +107,20 @@ class IsUnlocked extends Component<Props, State> {
handleSubmit = async (e: SyntheticEvent<HTMLFormElement>) => {
e.preventDefault()
const { settings, unlock, fetchAccounts } = this.props
const { unlock, fetchAccounts } = this.props
const { inputValue } = this.state
if (bcrypt.compareSync(inputValue.password, get(settings, 'password.value'))) {
setEncryptionKey('accounts', inputValue.password)
await fetchAccounts()
const isAccountsDecrypted = await db.hasBeenDecrypted('app', 'accounts')
try {
if (!isAccountsDecrypted) {
await db.setEncryptionKey('app', 'accounts', inputValue.password)
await fetchAccounts()
} else if (!db.isEncryptionKeyCorrect('app', 'accounts', inputValue.password)) {
throw new PasswordIncorrectError()
}
unlock()
this.setState({
...defaultState,
})
} else {
this.setState(defaultState)
} catch (err) {
this.setState({ incorrectPassword: new PasswordIncorrectError() })
}
}

33
src/components/Onboarding/steps/SetPassword.js

@ -1,10 +1,11 @@
// @flow
import React, { PureComponent, Fragment } from 'react'
import bcrypt from 'bcryptjs'
import { connect } from 'react-redux'
import { colors } from 'styles/theme'
import { setEncryptionKey } from 'helpers/db'
import db from 'helpers/db'
import { saveSettings } from 'actions/settings'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
@ -30,16 +31,24 @@ type State = {
confirmPassword: string,
}
const mapDispatchToProps = {
saveSettings,
}
const INITIAL_STATE = {
currentPassword: '',
newPassword: '',
confirmPassword: '',
}
class SetPassword extends PureComponent<StepProps, State> {
type Props = StepProps & {
saveSettings: any => void,
}
class SetPassword extends PureComponent<Props, State> {
state = INITIAL_STATE
handleSave = (e: SyntheticEvent<HTMLFormElement>) => {
handleSave = async (e: SyntheticEvent<HTMLFormElement>) => {
if (e) {
e.preventDefault()
}
@ -47,11 +56,10 @@ class SetPassword extends PureComponent<StepProps, State> {
return
}
const { newPassword } = this.state
const { nextStep, savePassword } = this.props
const { nextStep, saveSettings } = this.props
setEncryptionKey('accounts', newPassword)
const hash = newPassword ? bcrypt.hashSync(newPassword, 8) : undefined
savePassword(hash)
await db.setEncryptionKey('app', 'accounts', newPassword)
saveSettings({ hasPassword: true })
this.handleReset()
nextStep()
}
@ -71,7 +79,7 @@ class SetPassword extends PureComponent<StepProps, State> {
const { nextStep, prevStep, t, settings, onboarding } = this.props
const { newPassword, currentPassword, confirmPassword } = this.state
const isPasswordEnabled = settings.password.isEnabled === true
const hasPassword = settings.hasPassword === true
const disclaimerNotes = [
{
@ -110,7 +118,7 @@ class SetPassword extends PureComponent<StepProps, State> {
<Box align="center" mt={2}>
<PasswordForm
onSubmit={this.handleSave}
isPasswordEnabled={isPasswordEnabled}
hasPassword={hasPassword}
newPassword={newPassword}
currentPassword={currentPassword}
confirmPassword={confirmPassword}
@ -151,4 +159,7 @@ class SetPassword extends PureComponent<StepProps, State> {
}
}
export default SetPassword
export default connect(
null,
mapDispatchToProps,
)(SetPassword)

52
src/components/SettingsPage/DisablePasswordModal.js

@ -1,9 +1,9 @@
// @flow
import React, { PureComponent } from 'react'
import bcrypt from 'bcryptjs'
import { createCustomErrorClass } from 'helpers/errors'
import db from 'helpers/db'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
import InputPassword from 'components/base/InputPassword'
@ -17,8 +17,6 @@ const PasswordIncorrectError = createCustomErrorClass('PasswordIncorrect')
type Props = {
t: T,
onClose: Function,
isPasswordEnabled: boolean,
currentPasswordHash: string,
onChangePassword: Function,
}
@ -42,16 +40,14 @@ class DisablePasswordModal extends PureComponent<Props, State> {
}
const { currentPassword } = this.state
const { isPasswordEnabled, currentPasswordHash, onChangePassword } = this.props
if (isPasswordEnabled) {
if (!bcrypt.compareSync(currentPassword, currentPasswordHash)) {
this.setState({ incorrectPassword: new PasswordIncorrectError() })
return
}
onChangePassword('')
} else {
onChangePassword('')
const { onChangePassword } = this.props
if (!db.isEncryptionKeyCorrect('app', 'accounts', currentPassword)) {
this.setState({ incorrectPassword: new PasswordIncorrectError() })
return
}
onChangePassword('')
}
handleInputChange = (key: string) => (value: string) => {
@ -64,7 +60,7 @@ class DisablePasswordModal extends PureComponent<Props, State> {
handleReset = () => this.setState(INITIAL_STATE)
render() {
const { t, isPasswordEnabled, onClose, ...props } = this.props
const { t, onClose, ...props } = this.props
const { currentPassword, incorrectPassword } = this.state
return (
<Modal
@ -79,26 +75,24 @@ class DisablePasswordModal extends PureComponent<Props, State> {
<Box ff="Open Sans" color="smoke" fontSize={4} textAlign="center" px={4}>
{t('app:password.disablePassword.desc')}
<Box px={7} mt={4} flow={3}>
{isPasswordEnabled && (
<Box flow={1}>
<Label htmlFor="password">
{t('app:password.inputFields.currentPassword.label')}
</Label>
<InputPassword
autoFocus
type="password"
id="password"
onChange={this.handleInputChange('currentPassword')}
value={currentPassword}
error={incorrectPassword}
/>
</Box>
)}
<Box flow={1}>
<Label htmlFor="password">
{t('app:password.inputFields.currentPassword.label')}
</Label>
<InputPassword
autoFocus
type="password"
id="password"
onChange={this.handleInputChange('currentPassword')}
value={currentPassword}
error={incorrectPassword}
/>
</Box>
</Box>
</Box>
</ModalContent>
<ModalFooter horizontal align="center" justify="flex-end" flow={2}>
<Button small type="Button small" onClick={onClose}>
<Button small type="button" onClick={onClose}>
{t('app:common.cancel')}
</Button>
<Button

58
src/components/SettingsPage/DisablePasswordButton.js → src/components/SettingsPage/PasswordButton.js

@ -4,14 +4,10 @@ import React, { Fragment, PureComponent } from 'react'
import { connect } from 'react-redux'
import { translate } from 'react-i18next'
import type { T } from 'types/common'
import { createStructuredSelector } from 'reselect'
import bcrypt from 'bcryptjs'
import { setEncryptionKey } from 'helpers/db'
import db from 'helpers/db'
import { hasPasswordSelector } from 'reducers/settings'
import { cleanAccountsCache } from 'actions/accounts'
import { saveSettings } from 'actions/settings'
import { storeSelector } from 'reducers/settings'
import type { SettingsState } from 'reducers/settings'
import { unlock } from 'reducers/application' // FIXME should be in actions
import Track from 'analytics/Track'
import Switch from 'components/base/Switch'
import Box from 'components/base/Box'
@ -19,22 +15,19 @@ import Button from 'components/base/Button'
import PasswordModal from './PasswordModal'
import DisablePasswordModal from './DisablePasswordModal'
const mapStateToProps = createStructuredSelector({
// FIXME in future we should use dedicated password selector and a savePassword action (you don't know the shape of settings)
settings: storeSelector,
const mapStateToProps = state => ({
hasPassword: hasPasswordSelector(state),
})
const mapDispatchToProps = {
unlock,
cleanAccountsCache,
saveSettings,
}
type Props = {
t: T,
unlock: () => void,
settings: SettingsState,
saveSettings: Function,
hasPassword: boolean,
}
type State = {
@ -42,25 +35,20 @@ type State = {
isDisablePasswordModalOpened: boolean,
}
class DisablePasswordButton extends PureComponent<Props, State> {
class PasswordButton extends PureComponent<Props, State> {
state = {
isPasswordModalOpened: false,
isDisablePasswordModalOpened: false,
}
setPassword = password => {
const { saveSettings, unlock } = this.props
window.requestIdleCallback(() => {
setEncryptionKey('accounts', password)
const hash = password ? bcrypt.hashSync(password, 8) : undefined
saveSettings({
password: {
isEnabled: hash !== undefined,
value: hash,
},
})
unlock()
})
setPassword = async password => {
if (password) {
this.props.saveSettings({ hasPassword: true })
await db.setEncryptionKey('app', 'accounts', password)
} else {
this.props.saveSettings({ hasPassword: false })
await db.removeEncryptionKey('app', 'accounts')
}
}
handleOpenPasswordModal = () => this.setState({ isPasswordModalOpened: true })
@ -87,20 +75,20 @@ class DisablePasswordButton extends PureComponent<Props, State> {
}
render() {
const { t, settings } = this.props
const { t, hasPassword } = this.props
const { isDisablePasswordModalOpened, isPasswordModalOpened } = this.state
const isPasswordEnabled = settings.password.isEnabled === true
return (
<Fragment>
<Track onUpdate event={isPasswordEnabled ? 'PasswordEnabled' : 'PasswordDisabled'} />
<Track onUpdate event={hasPassword ? 'PasswordEnabled' : 'PasswordDisabled'} />
<Box horizontal flow={2} align="center">
{isPasswordEnabled && (
{hasPassword && (
<Button small onClick={this.handleOpenPasswordModal}>
{t('app:settings.profile.changePassword')}
</Button>
)}
<Switch isChecked={isPasswordEnabled} onChange={this.handleChangePasswordCheck} />
<Switch isChecked={hasPassword} onChange={this.handleChangePasswordCheck} />
</Box>
<PasswordModal
@ -108,8 +96,7 @@ class DisablePasswordButton extends PureComponent<Props, State> {
isOpened={isPasswordModalOpened}
onClose={this.handleClosePasswordModal}
onChangePassword={this.handleChangePassword}
isPasswordEnabled={isPasswordEnabled}
currentPasswordHash={settings.password.value}
hasPassword={hasPassword}
/>
<DisablePasswordModal
@ -117,8 +104,7 @@ class DisablePasswordButton extends PureComponent<Props, State> {
isOpened={isDisablePasswordModalOpened}
onClose={this.handleCloseDisablePasswordModal}
onChangePassword={this.handleChangePassword}
isPasswordEnabled={isPasswordEnabled}
currentPasswordHash={settings.password.value}
hasPassword={hasPassword}
/>
</Fragment>
)
@ -129,5 +115,5 @@ export default translate()(
connect(
mapStateToProps,
mapDispatchToProps,
)(DisablePasswordButton),
)(PasswordButton),
)

8
src/components/SettingsPage/PasswordForm.js

@ -14,7 +14,7 @@ const PasswordsDontMatchError = createCustomErrorClass('PasswordsDontMatch')
type Props = {
t: T,
isPasswordEnabled: boolean,
hasPassword: boolean,
currentPassword: string,
newPassword: string,
confirmPassword: string,
@ -28,7 +28,7 @@ class PasswordForm extends PureComponent<Props> {
render() {
const {
t,
isPasswordEnabled,
hasPassword,
currentPassword,
newPassword,
incorrectPassword,
@ -41,7 +41,7 @@ class PasswordForm extends PureComponent<Props> {
return (
<form onSubmit={onSubmit}>
<Box px={7} mt={4} flow={3}>
{isPasswordEnabled && (
{hasPassword && (
<Box flow={1} mb={5}>
<Label htmlFor="currentPassword">
{t('app:password.inputFields.currentPassword.label')}
@ -59,7 +59,7 @@ class PasswordForm extends PureComponent<Props> {
<Label htmlFor="newPassword">{t('app:password.inputFields.newPassword.label')}</Label>
<InputPassword
style={{ mt: 4, width: 240 }}
autoFocus={!isPasswordEnabled}
autoFocus={!hasPassword}
id="newPassword"
onChange={onChange('newPassword')}
value={newPassword}

19
src/components/SettingsPage/PasswordModal.js

@ -1,10 +1,10 @@
// @flow
import React, { PureComponent } from 'react'
import bcrypt from 'bcryptjs'
import type { T } from 'types/common'
import db from 'helpers/db'
import { createCustomErrorClass } from 'helpers/errors'
import Box from 'components/base/Box'
import Button from 'components/base/Button'
@ -18,8 +18,7 @@ type Props = {
t: T,
onClose: () => void,
onChangePassword: (?string) => void,
isPasswordEnabled: boolean,
currentPasswordHash: string,
hasPassword: boolean,
}
type State = {
@ -49,9 +48,9 @@ class PasswordModal extends PureComponent<Props, State> {
return
}
const { isPasswordEnabled, currentPasswordHash, onChangePassword } = this.props
if (isPasswordEnabled) {
if (!bcrypt.compareSync(currentPassword, currentPasswordHash)) {
const { hasPassword, onChangePassword } = this.props
if (hasPassword) {
if (!db.isEncryptionKeyCorrect('app', 'accounts', currentPassword)) {
this.setState({ incorrectPassword: new PasswordIncorrectError() })
return
}
@ -76,7 +75,7 @@ class PasswordModal extends PureComponent<Props, State> {
}
render() {
const { t, isPasswordEnabled, onClose, ...props } = this.props
const { t, hasPassword, onClose, ...props } = this.props
const { currentPassword, newPassword, incorrectPassword, confirmPassword } = this.state
return (
<Modal
@ -85,14 +84,14 @@ class PasswordModal extends PureComponent<Props, State> {
onClose={onClose}
render={({ onClose }) => (
<ModalBody onClose={onClose}>
{isPasswordEnabled ? (
{hasPassword ? (
<ModalTitle>{t('app:password.changePassword.title')}</ModalTitle>
) : (
<ModalTitle>{t('app:password.setPassword.title')}</ModalTitle>
)}
<ModalContent>
<Box ff="Museo Sans|Regular" color="dark" textAlign="center" mb={2} mt={3}>
{isPasswordEnabled
{hasPassword
? t('app:password.changePassword.subTitle')
: t('app:password.setPassword.subTitle')}
</Box>
@ -101,7 +100,7 @@ class PasswordModal extends PureComponent<Props, State> {
</Box>
<PasswordForm
onSubmit={this.handleSave}
isPasswordEnabled={isPasswordEnabled}
hasPassword={hasPassword}
newPassword={newPassword}
currentPassword={currentPassword}
confirmPassword={confirmPassword}

4
src/components/SettingsPage/sections/Display.js

@ -16,7 +16,7 @@ import LanguageSelect from '../LanguageSelect'
import CounterValueSelect from '../CounterValueSelect'
import CounterValueExchangeSelect from '../CounterValueExchangeSelect'
import RegionSelect from '../RegionSelect'
import DisablePasswordButton from '../DisablePasswordButton'
import PasswordButton from '../PasswordButton'
import DevModeButton from '../DevModeButton'
import SentryLogsButton from '../SentryLogsButton'
import ShareAnalyticsButton from '../ShareAnalyticsButton'
@ -90,7 +90,7 @@ class TabGeneral extends PureComponent<Props> {
title={t('app:settings.profile.password')}
desc={t('app:settings.profile.passwordDesc')}
>
<DisablePasswordButton />
<PasswordButton />
</Row>
<Row
title={t('app:settings.profile.reportErrors')}

2
src/components/SettingsPage/sections/Tools.js

@ -1,5 +1,7 @@
// @flow
/* eslint-disable react/jsx-no-literals */
import React, { PureComponent } from 'react'
import { translate } from 'react-i18next'
import Box, { Card } from 'components/base/Box'

4
src/components/TopBar/index.js

@ -11,7 +11,7 @@ import type { Location, RouterHistory } from 'react-router'
import type { T } from 'types/common'
import { lock } from 'reducers/application'
import { hasPassword } from 'reducers/settings'
import { hasPasswordSelector } from 'reducers/settings'
import { hasAccountsSelector } from 'reducers/accounts'
import { openModal } from 'reducers/modals'
@ -54,7 +54,7 @@ const Bar = styled.div`
`
const mapStateToProps = state => ({
hasPassword: hasPassword(state),
hasPassword: hasPasswordSelector(state),
hasAccounts: hasAccountsSelector(state),
})

19
src/components/base/InputPassword/index.js

@ -66,18 +66,23 @@ class InputPassword extends PureComponent<Props, State> {
inputType: 'password',
}
componentWillUnmount() {
this._isUnmounted = true
}
_isUnmounted = false
toggleInputType = () =>
this.setState(prev => ({
inputType: prev.inputType === 'text' ? 'password' : 'text',
}))
debouncePasswordStrength = debounce(
v =>
this.setState({
passwordStrength: getPasswordStrength(v),
}),
150,
)
debouncePasswordStrength = debounce(v => {
if (this._isUnmounted) return
this.setState({
passwordStrength: getPasswordStrength(v),
})
}, 150)
handleChange = (v: string) => {
const { onChange } = this.props

4
src/config/errors.js

@ -12,3 +12,7 @@ export const UserRefusedAddress = createCustomErrorClass('UserRefusedAddress')
export const WrongDeviceForAccount = createCustomErrorClass('WrongDeviceForAccount')
export const DeviceNotGenuineError = createCustomErrorClass('DeviceNotGenuine')
export const DeviceGenuineSocketEarlyClose = createCustomErrorClass('DeviceGenuineSocketEarlyClose')
// db stuff, no need to translate
export const NoDBPathGiven = createCustomErrorClass('NoDBPathGiven')
export const DBWrongPassword = createCustomErrorClass('DBWrongPassword')

13
src/helpers/db/db-storybook.js

@ -1,13 +0,0 @@
// @flow
const noop = () => {}
module.exports = {
init: noop,
get: noop,
set: noop,
getIn: noop,
setIn: noop,
cleanCache: noop,
resetAll: noop,
}

104
src/helpers/db/db.js

@ -1,104 +0,0 @@
// @flow
import logger from 'logger'
import Store from 'electron-store'
import set from 'lodash/set'
import get from 'lodash/get'
import { decodeAccountsModel, encodeAccountsModel } from 'reducers/accounts'
type DBKey = 'settings' | 'accounts' | 'countervalues' | 'user' | 'migrations'
const encryptionKey = {}
const store = key =>
new Store({
name: key,
defaults: {
data: null,
},
encryptionKey: encryptionKey[key],
})
export function setEncryptionKey(key: DBKey, value?: string) {
encryptionKey[key] = value
}
const transforms = {
get: {
accounts: decodeAccountsModel,
},
set: {
accounts: encodeAccountsModel,
},
}
function middleware(type: 'get' | 'set', key: string, data: any) {
const t = transforms[type][key]
if (t) {
data = t(data)
}
return data
}
export default {
// If the db doesn't exists for that key, init it, with the default value provided
init: (key: DBKey, defaults: any) => {
const db = store(key)
const data = db.get('data')
if (!data) {
db.set('data', defaults)
}
},
// TODO flowtype this. we should be able to express all the possible entries and their expected type (with a union type)
get: (key: DBKey, defaults: any): any => {
const db = store(key)
const data = db.get('data', defaults)
logger.onDB('read', key)
return middleware('get', key, data)
},
set: (key: DBKey, val: any) => {
const db = store(key)
val = middleware('set', key, val)
logger.onDB('write', key)
db.set('data', val)
return val
},
getIn: (key: DBKey, path: string, defaultValue: any) => {
const db = store(key)
let data = db.get('data')
data = middleware('get', key, data)
return get(data, path, defaultValue)
},
setIn: (key: DBKey, path: string, val: any) => {
const db = store(key)
const data = db.get('data')
val = middleware('set', key, val)
set(data, path, val)
db.set('data', data)
return val
},
cleanCache: () => {
// Only remove cache store
const keys = ['countervalues']
keys.forEach(k => {
const db = store(k)
logger.onDB('clear', k)
db.clear()
})
},
resetAll: () => {
const keys = ['settings', 'accounts', 'countervalues']
keys.forEach(k => {
const db = store(k)
logger.onDB('clear', k)
db.clear()
})
},
}

226
src/helpers/db/db.spec.js

@ -0,0 +1,226 @@
import os from 'os'
import path from 'path'
import fs from 'fs'
import rimrafModule from 'rimraf'
import db from 'helpers/db'
import { promisify } from 'helpers/promise'
const rimraf = promisify(rimrafModule)
const fsReadFile = promisify(fs.readFile)
const fsWriteFile = promisify(fs.writeFile)
const fsMkdir = promisify(fs.mkdir)
const accountsTransform = {
get: accounts => accounts.map(account => ({ ...account, balance: Number(account.balance) })),
set: accounts => accounts.map(account => ({ ...account, balance: account.balance.toString() })),
}
const fakeAccounts = [{ name: 'a', balance: 100 }, { name: 'b', balance: 200 }]
async function createRandomTmpDir() {
const p = path.resolve(os.tmpdir(), `tmp-${Math.random()}`)
await rimraf(p)
await fsMkdir(p)
return p
}
describe('db - without init', () => {
test('throw if trying to get key while db not initiated', async () => {
let err
try {
await db.getKey('app', 'accounts')
} catch (e) {
err = e
}
expect(err).toBeDefined()
expect(err.name).toBe('NoDBPathGiven')
})
test('handle the case where db file does not exists', async () => {
let err
try {
const dbPath = await createRandomTmpDir()
db.init(dbPath)
const dbContent = await db.load('app')
expect(dbContent).toEqual({})
await rimraf(dbPath)
} catch (e) {
err = e
}
expect(err).toBeUndefined()
})
})
describe('db', () => {
const dbPath = path.resolve(os.tmpdir(), 'ledger-live-test-db')
beforeEach(async () => {
await rimraf(dbPath)
await fsMkdir(dbPath)
db.init(dbPath)
})
test('set and get key', async () => {
const a = await db.getKey('app', 'something')
expect(a).toBeUndefined()
await db.setKey('app', 'something', 'foo')
const b = await db.getKey('app', 'something')
expect(b).toBe('foo')
})
test('set and get key, even if nested', async () => {
await db.setKey('app', 'something.is.good', 'foo')
const a = await db.getKey('app', 'something.is.good')
expect(a).toBe('foo')
})
test('get the whole namespace', async () => {
await db.setKey('app', 'something.is.good', 'foo')
const a = await db.getNamespace('app')
expect(a).toEqual({ something: { is: { good: 'foo' } } })
})
test('set the whole namespace', async () => {
await db.setNamespace('app', { foo: 'bar' })
const a = await db.getNamespace('app')
expect(a).toEqual({ foo: 'bar' })
})
test('handle default value if value not set', async () => {
const a = await db.getKey('app', 'something.is.good', 57)
expect(a).toBe(57)
})
test('encrypt data to filesystem', async () => {
const data = { this: 'is', sparta: true }
let content
let parsed
// let's try without encrypting
await db.setKey('app', 'shouldBeEncrypted', data)
const filePath = path.resolve(dbPath, 'app.json')
content = await fsReadFile(filePath, 'utf-8')
parsed = JSON.parse(content).data
expect(parsed.shouldBeEncrypted).toEqual(data)
// mark the field as encrypted
await db.setEncryptionKey('app', 'shouldBeEncrypted', 'passw0rd')
// let's see if it worked
content = await fsReadFile(filePath, 'utf-8')
parsed = JSON.parse(content).data
const expected = '+UexwDUPgM8mYaandbTUzTMdmZDe+/yd77zOLCHcIWk='
expect(parsed.shouldBeEncrypted).toEqual(expected)
})
test('retrieve encrypted data, after db load', async () => {
const tmpDir = path.resolve(os.tmpdir(), 'with-encrypted-field')
await rimraf(tmpDir)
await fsMkdir(tmpDir)
const encryptedData =
'{"data":{ "shouldBeEncrypted": "+UexwDUPgM8mYaandbTUzTMdmZDe+/yd77zOLCHcIWk=" }}'
await fsWriteFile(path.resolve(tmpDir, 'app.json'), encryptedData)
db.init(tmpDir)
const encrypted = await db.getKey('app', 'shouldBeEncrypted')
expect(encrypted).toBe('+UexwDUPgM8mYaandbTUzTMdmZDe+/yd77zOLCHcIWk=')
await db.setEncryptionKey('app', 'shouldBeEncrypted', 'passw0rd')
const decoded = await db.getKey('app', 'shouldBeEncrypted')
expect(decoded).toEqual({ this: 'is', sparta: true })
await rimraf(tmpDir)
})
test('handle wrong encryption key', async () => {
await db.setKey('app', 'foo', { some: 'data' })
await db.setEncryptionKey('app', 'foo', 'passw0rd')
db.init(dbPath)
const d = await db.getKey('app', 'foo.some')
expect(d).toBe(undefined)
let err
try {
await db.setEncryptionKey('app', 'foo', 'totally not the passw0rd')
} catch (e) {
err = e
}
expect(err).toBeDefined()
expect(err.name).toBe('DBWrongPassword')
await db.setEncryptionKey('app', 'foo', 'passw0rd')
const e = await db.getKey('app', 'foo.some')
expect(e).toBe('data')
})
test('detect if field is encrypted or not', async () => {
let isDecrypted
await db.setKey('app', 'encryptedField', { some: 'data' })
await db.setEncryptionKey('app', 'encryptedField', 'passw0rd')
db.init(dbPath)
const k = await db.getKey('app', 'encryptedField')
expect(k).toBe('HNEETQf+9An6saxmA/X8zg==')
isDecrypted = await db.hasBeenDecrypted('app', 'encryptedField')
expect(isDecrypted).toBe(false)
await db.setEncryptionKey('app', 'encryptedField', 'passw0rd')
isDecrypted = await db.hasBeenDecrypted('app', 'encryptedField')
expect(isDecrypted).toBe(true)
const value = await db.getKey('app', 'encryptedField')
expect(value).toEqual({ some: 'data' })
})
test('handle transformations', async () => {
db.registerTransform('app', 'accounts', accountsTransform)
await db.setKey('app', 'accounts', fakeAccounts)
const filePath = path.resolve(dbPath, 'app.json')
const fileContent = await fsReadFile(filePath, 'utf-8')
// expect transform to have written strings
const expectedFile =
'{"data":{"accounts":[{"name":"a","balance":"100"},{"name":"b","balance":"200"}]}}'
expect(fileContent).toBe(expectedFile)
db.init(dbPath)
db.registerTransform('app', 'accounts', accountsTransform)
// expect transform to have loaded numbers
const accounts = await db.getKey('app', 'accounts')
expect(accounts).toEqual(fakeAccounts)
})
test('can handle transform on an encrypted field', async () => {
let accounts
db.registerTransform('app', 'accounts', accountsTransform)
await db.setEncryptionKey('app', 'accounts', 'passw0rd')
await db.setKey('app', 'accounts', fakeAccounts)
accounts = await db.getKey('app', 'accounts')
expect(accounts).toEqual(fakeAccounts)
db.init(dbPath)
db.registerTransform('app', 'accounts', accountsTransform)
await db.setEncryptionKey('app', 'accounts', 'passw0rd')
accounts = await db.getKey('app', 'accounts')
expect(accounts).toEqual(fakeAccounts)
})
test('check if password is correct', async () => {
let isEncryptionKeyCorrect
await db.setEncryptionKey('app', 'verySecureField', 'h0dl')
await db.setKey('app', 'verySecureField', { much: { secure: { data: true } } })
const filePath = path.resolve(dbPath, 'app.json')
const content = await fsReadFile(filePath, 'utf-8')
const expected =
'{"data":{"verySecureField":"i9SyvjaWm/UVpmuyeChmKjSuiWJuMxEJhhvUhvleRoe6gpAOgBWqREB+CRO6yxkD"}}'
expect(content).toBe(expected)
isEncryptionKeyCorrect = db.isEncryptionKeyCorrect('app', 'verySecureField', 'h0dl')
expect(isEncryptionKeyCorrect).toBe(true)
isEncryptionKeyCorrect = db.isEncryptionKeyCorrect('app', 'verySecureField', 'never-h0dl')
expect(isEncryptionKeyCorrect).toBe(false)
})
test('inform is a field has an encryption key', async () => {
let hasEncryptionKey
await db.setEncryptionKey('app', 'verySecureField', 'h0dl')
hasEncryptionKey = db.hasEncryptionKey('app', 'verySecureField')
expect(hasEncryptionKey).toBe(true)
hasEncryptionKey = db.hasEncryptionKey('app', 'veryInexistantField')
expect(hasEncryptionKey).toBe(false)
})
})

292
src/helpers/db/index.js

@ -1,3 +1,291 @@
const db = process.env.STORYBOOK_ENV ? require('./db-storybook') : require('./db')
// @flow
module.exports = db
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import cloneDeep from 'lodash/cloneDeep'
import writeFileAtomicModule from 'write-file-atomic'
import get from 'lodash/get'
import set from 'lodash/set'
import logger from 'logger'
import { promisify } from 'helpers/promise'
import { NoDBPathGiven, DBWrongPassword } from 'config/errors'
type Transform = {
get: any => any,
set: any => any,
}
const fsReadFile = promisify(fs.readFile)
const fsUnlink = promisify(fs.unlink)
const writeFileAtomic = promisify(writeFileAtomicModule)
const ALGORITHM = 'aes-256-cbc'
let queue = Promise.resolve()
let DBPath = null
let memoryNamespaces = {}
let encryptionKeys = {}
let transforms = {}
/**
* Reset memory state, db path, encryption keys, transforms..
*/
function init(_DBPath: string) {
DBPath = _DBPath
memoryNamespaces = {}
encryptionKeys = {}
transforms = {}
}
/**
* Register a transformation for a given namespace and keyPath
* it will be used when reading/writing from/to file
*/
function registerTransform(ns: string, keyPath: string, transform: Transform) {
if (!transforms[ns]) transforms[ns] = {}
transforms[ns][keyPath] = transform
}
/**
* Load a namespace, using <file>.json
*/
async function load(ns: string): Promise<mixed> {
try {
if (!DBPath) throw new NoDBPathGiven()
const filePath = path.resolve(DBPath, `${ns}.json`)
const fileContent = await fsReadFile(filePath)
const { data } = JSON.parse(fileContent)
memoryNamespaces[ns] = data
// transform fields
for (const keyPath in transforms[ns]) {
if (transforms[ns].hasOwnProperty(keyPath)) {
const transform = transforms[ns][keyPath]
const val = get(memoryNamespaces[ns], keyPath)
// if value is string, it's encrypted, so we don't want to transform
if (typeof val === 'string') continue // eslint-disable-line no-continue
set(memoryNamespaces[ns], keyPath, transform.get(val))
}
}
} catch (err) {
if (err.code === 'ENOENT') {
memoryNamespaces[ns] = {}
await save(ns)
} else {
logger.error(err)
throw err
}
}
return memoryNamespaces[ns]
}
async function ensureNSLoaded(ns: string) {
if (!memoryNamespaces[ns]) {
await load(ns)
}
}
/**
* Register a keyPath in db that is encrypted
* This will decrypt the keyPath at this moment, and will be used
* in `save` to encrypt it back
*/
async function setEncryptionKey(ns: string, keyPath: string, encryptionKey: string): Promise<any> {
if (!encryptionKeys[ns]) encryptionKeys[ns] = {}
encryptionKeys[ns][keyPath] = encryptionKey
const val = await getKey(ns, keyPath, null)
// no need to decode if already decoded
if (!val || typeof val !== 'string') {
return save(ns)
}
try {
const decipher = crypto.createDecipher(ALGORITHM, encryptionKey)
const raw = decipher.update(val, 'base64', 'utf8') + decipher.final('utf8')
let decrypted = JSON.parse(raw)
// handle the case when we just migrated from the previous storage
// which stored the data in binary with a `data` key
if (ns === 'app' && keyPath === 'accounts' && decrypted.data) {
decrypted = decrypted.data
}
// apply transform if needed
const transform = get(transforms, `${ns}.${keyPath}`)
if (transform) {
decrypted = transform.get(decrypted)
}
// only set decrypted data in memory
set(memoryNamespaces[ns], keyPath, decrypted)
return save(ns)
} catch (err) {
throw new DBWrongPassword()
}
}
async function removeEncryptionKey(ns: string, keyPath: string) {
set(encryptionKeys, `${ns}.${keyPath}`, undefined)
return save(ns)
}
/**
* Set a key in the given namespace
*/
async function setKey(ns: string, keyPath: string, value: any): Promise<any> {
logger.onDB('write', `${ns}:${keyPath}`)
await ensureNSLoaded(ns)
set(memoryNamespaces[ns], keyPath, value)
return save(ns)
}
/**
* Get a key in the given namespace
*/
async function getKey(ns: string, keyPath: string, defaultValue?: any): Promise<any> {
logger.onDB('read', `${ns}:${keyPath}`)
await ensureNSLoaded(ns)
if (!keyPath) return memoryNamespaces[ns] || defaultValue
return get(memoryNamespaces[ns], keyPath, defaultValue)
}
/**
* Get whole namespace
*/
async function getNamespace(ns: string, defaultValue?: any) {
logger.onDB('read', ns)
await ensureNSLoaded(ns)
return memoryNamespaces[ns] || defaultValue
}
async function setNamespace(ns: string, value: any) {
logger.onDB('write', ns)
set(memoryNamespaces, ns, value)
return save(ns)
}
/**
* Check if a key has been decrypted
*
* /!\ it consider encrypted if it's string and can't JSON.parse, so
* can brings false-positive if bad used
*/
async function hasBeenDecrypted(ns: string, keyPath: string): Promise<boolean> {
const v = await getKey(ns, keyPath)
if (typeof v !== 'string') return true
try {
JSON.parse(v)
return true
} catch (err) {
return false
}
}
/**
* Save given namespace to corresponding file, in atomic way
*/
async function saveToDisk(ns: string) {
if (!DBPath) throw new NoDBPathGiven()
await ensureNSLoaded(ns)
// cloning because we are mutating the obj
const clone = cloneDeep(memoryNamespaces[ns])
// transform fields
if (transforms[ns]) {
for (const keyPath in transforms[ns]) {
if (transforms[ns].hasOwnProperty(keyPath)) {
const transform = transforms[ns][keyPath]
const val = get(clone, keyPath)
// we don't want to transform encrypted fields (that have not being decrypted yet)
if (!val || typeof val === 'string') continue // eslint-disable-line no-continue
set(clone, keyPath, transform.set(val))
}
}
}
// encrypt fields
if (encryptionKeys[ns]) {
for (const keyPath in encryptionKeys[ns]) {
if (encryptionKeys[ns].hasOwnProperty(keyPath)) {
const encryptionKey = encryptionKeys[ns][keyPath]
if (!encryptionKey) continue // eslint-disable-line no-continue
const val = get(clone, keyPath)
if (!val) continue // eslint-disable-line no-continue
const cipher = crypto.createCipher(ALGORITHM, encryptionKey)
const encrypted =
cipher.update(JSON.stringify(val), 'utf8', 'base64') + cipher.final('base64')
set(clone, keyPath, encrypted)
}
}
}
const fileContent = JSON.stringify({ data: clone })
await writeFileAtomic(path.resolve(DBPath, `${ns}.json`), fileContent)
}
function save(ns: string) {
queue = queue.then(() => saveToDisk(ns))
return queue
}
async function cleanCache() {
logger.onDB('clean cache')
await setKey('app', 'countervalues', null)
await save('app')
}
async function resetAll() {
logger.onDB('reset all')
if (!DBPath) throw new NoDBPathGiven()
memoryNamespaces.app = null
await fsUnlink(path.resolve(DBPath, 'app.json'))
}
function isEncryptionKeyCorrect(ns: string, keyPath: string, encryptionKey: string) {
try {
return encryptionKeys[ns][keyPath] === encryptionKey
} catch (err) {
return false
}
}
function hasEncryptionKey(ns: string, keyPath: string) {
try {
return !!encryptionKeys[ns][keyPath]
} catch (err) {
return false
}
}
function getDBPath() {
if (!DBPath) throw new Error('Trying to get db path but it is not initialized')
return DBPath
}
export default {
init,
load,
registerTransform,
setEncryptionKey,
removeEncryptionKey,
isEncryptionKeyCorrect,
hasEncryptionKey,
setKey,
getKey,
getNamespace,
setNamespace,
hasBeenDecrypted,
save,
cleanCache,
resetAll,
getDBPath,
}

8
src/helpers/promise.js

@ -64,3 +64,11 @@ export function createCancelablePolling(
})
return { unsubscribe, promise }
}
export const promisify = (fn: any) => (...args: any) =>
new Promise((resolve, reject) =>
fn(...args, (err: Error, res: any) => {
if (err) return reject(err)
return resolve(res)
}),
)

6
src/helpers/user.js

@ -5,11 +5,11 @@ import uuid from 'uuid/v4'
// a user is an anonymous way to identify a same instance of the app
export default () => {
let user = db.get('user')
export default async () => {
let user = await db.getKey('app', 'user')
if (!user) {
user = { id: uuid() }
db.set('user', user)
db.setKey('app', 'user', user)
}
return user
}

5
src/logger/index.js

@ -1,3 +1,6 @@
const logger = process.env.STORYBOOK_ENV ? require('./logger-storybook') : require('./logger')
const logger =
process.env.STORYBOOK_ENV || process.env.NODE_ENV === 'test'
? require('./logger-storybook')
: require('./logger')
module.exports = logger

15
src/main/app.js

@ -12,12 +12,15 @@ import {
import menu from 'main/menu'
import db from 'helpers/db'
import { i } from 'helpers/staticPath'
import resolveUserDataDirectory from 'helpers/resolveUserDataDirectory'
import { terminateAllTheThings } from './terminator'
// necessary to prevent win from being garbage collected
let mainWindow = null
db.init(resolveUserDataDirectory())
const isSecondInstance = app.makeSingleInstance(() => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
@ -66,7 +69,7 @@ const saveWindowSettings = window => {
'resize',
debounce(() => {
const [width, height] = window.getSize()
db.setIn('settings', `window.${window.name}.dimensions`, { width, height })
db.setKey('windowParams', `${window.name}.dimensions`, { width, height })
}, 100),
)
@ -74,7 +77,7 @@ const saveWindowSettings = window => {
'move',
debounce(() => {
const [x, y] = window.getPosition()
db.setIn('settings', `window.${window.name}.positions`, { x, y })
db.setKey('windowParams', `${window.name}.positions`, { x, y })
}, 100),
)
}
@ -91,9 +94,9 @@ const defaultWindowOptions = {
},
}
function createMainWindow() {
const savedDimensions = db.getIn('settings', 'window.MainWindow.dimensions', {})
const savedPositions = db.getIn('settings', 'window.MainWindow.positions', null)
async function createMainWindow() {
const savedDimensions = await db.getKey('app', 'MainWindow.dimensions', {})
const savedPositions = await db.getKey('app', 'MainWindow.positions', null)
const width = savedDimensions.width || DEFAULT_WINDOW_WIDTH
const height = savedDimensions.height || DEFAULT_WINDOW_HEIGHT
@ -178,5 +181,5 @@ app.on('ready', async () => {
Menu.setApplicationMenu(menu)
mainWindow = createMainWindow()
mainWindow = await createMainWindow()
})

9
src/main/bridge.js

@ -24,10 +24,15 @@ const LEDGER_CONFIG_DIRECTORY = app.getPath('userData')
let internalProcess
let userId = null
let sentryEnabled = false
const userId = user().id
sentry(() => sentryEnabled, userId)
async function init() {
const u = await user()
userId = u.id
sentry(() => sentryEnabled, userId)
}
init()
const killInternalProcess = () => {
if (internalProcess) {

12
src/middlewares/db.js

@ -1,3 +1,5 @@
// @flow
/* eslint-disable consistent-return */
import db from 'helpers/db'
@ -9,27 +11,27 @@ import CounterValues from 'helpers/countervalues'
let DB_MIDDLEWARE_ENABLED = true
// ability to temporary disable the db middleware from outside
export const disable = (ms = 1000) => {
export const disable = (ms: number = 1000) => {
DB_MIDDLEWARE_ENABLED = false
setTimeout(() => (DB_MIDDLEWARE_ENABLED = true), ms)
}
export default store => next => action => {
export default (store: any) => (next: any) => (action: any) => {
if (DB_MIDDLEWARE_ENABLED && action.type.startsWith('DB:')) {
const [, type] = action.type.split(':')
store.dispatch({ type, payload: action.payload })
const state = store.getState()
db.set('accounts', accountsSelector(state))
db.setKey('app', 'accounts', accountsSelector(state))
// ^ TODO ultimately we'll do same for accounts to drop DB: pattern
} else {
const oldState = store.getState()
const res = next(action)
const newState = store.getState()
if (oldState.countervalues !== newState.countervalues) {
db.set('countervalues', CounterValues.exportSelector(newState))
db.setKey('app', 'countervalues', CounterValues.exportSelector(newState))
}
if (areSettingsLoaded(newState) && oldState.settings !== newState.settings) {
db.set('settings', settingsExportSelector(newState))
db.setKey('app', 'settings', settingsExportSelector(newState))
}
return res
}

33
src/migrations/index.js

@ -3,42 +3,19 @@
import logger from 'logger'
import db from 'helpers/db'
import { delay } from 'helpers/promise'
import type { Migration } from './types'
export const migrations: Migration[] = [
/*
// TODO release when libcore will fix the issue (ensure it does everyting that is needed)
{
doc: 'libcore fixed an important bug on BCH that needs a cache clear',
run: async () => {
// Clear out accounts operations because will need a full refresh
const accounts: mixed = db.get('accounts')
if (accounts && Array.isArray(accounts)) {
for (const acc of accounts) {
if (acc && typeof acc === 'object') {
acc.operations = []
acc.pendingOperations = []
}
}
db.set('accounts', accounts)
}
db.cleanCache()
// await delay(500)
},
},
*/
]
import migrations from './migrations'
// Logic to run all the migrations based on what was not yet run:
export const runMigrations = async (): Promise<void> => {
const current = db.get('migrations')
const current = await db.getNamespace('migrations')
let { nonce } = current || { nonce: migrations.length }
const outdated = migrations.length - nonce
if (!outdated) {
if (!current) {
db.set('migrations', { nonce })
await db.setNamespace('migrations', { nonce })
}
return
}
@ -54,6 +31,6 @@ export const runMigrations = async (): Promise<void> => {
}
logger.log(`${outdated} migration(s) performed.`)
} finally {
db.set('migrations', { nonce })
await db.setNamespace('migrations', { nonce })
}
}

53
src/migrations/migrations.js

@ -0,0 +1,53 @@
// @flow
import fs from 'fs'
import path from 'path'
import { promisify } from 'helpers/promise'
import db from 'helpers/db'
import type { Migration } from './types'
const fsReadfile = promisify(fs.readFile)
const fsUnlink = promisify(fs.unlink)
const migrations: Migration[] = [
{
doc: 'merging multiple db files into one app file',
run: async () => {
const dbPath = db.getDBPath()
const legacyKeys = ['accounts', 'countervalues', 'settings', 'user']
const [accounts, countervalues, settings, user] = await Promise.all(
legacyKeys.map(key => getLegacyData(path.join(dbPath, `${key}.json`))),
)
const appData = { user, settings, accounts, countervalues }
await db.setNamespace('app', appData)
const hasPassword = await db.getKey('app', 'settings.password.isEnabled', false)
await db.setKey('app', 'settings.hasPassword', hasPassword)
await db.setKey('app', 'settings.password', undefined)
const windowParams = await db.getKey('app', 'settings.window')
await db.setKey('app', 'settings.window', undefined)
await db.setNamespace('windowParams', windowParams)
await Promise.all(legacyKeys.map(key => fsUnlink(path.join(dbPath, `${key}.json`))))
},
},
]
async function getLegacyData(filePath) {
let finalData
const fileContent = await fsReadfile(filePath, 'utf-8')
try {
const { data } = JSON.parse(fileContent)
finalData = data
} catch (err) {
// we assume we are in that case because file is encrypted
if (err instanceof SyntaxError) {
const buf = await fsReadfile(filePath)
return buf.toString('base64')
}
throw err
}
return finalData
}
export default migrations

96
src/migrations/migrations.spec.js

@ -0,0 +1,96 @@
import fs from 'fs'
import os from 'os'
import path from 'path'
import { spawn } from 'child_process'
import rimrafModule from 'rimraf'
import { BigNumber } from 'bignumber.js'
import { promisify } from 'helpers/promise'
import { runMigrations } from 'migrations'
import { decodeAccountsModel, encodeAccountsModel } from 'reducers/accounts'
import db from 'helpers/db'
const rimraf = promisify(rimrafModule)
const fsReaddir = promisify(fs.readdir)
const tmpDir = os.tmpdir()
const accountsTransform = {
get: decodeAccountsModel,
set: encodeAccountsModel,
}
describe('migration 1', () => {
describe('without encryption', () => {
test('merging db files', async () => {
const dir = await extractMock('userdata_v1.0.5_mock-01')
let files
db.init(dir)
files = await fsReaddir(dir)
expect(files).toEqual([
'accounts.json',
'countervalues.json',
'migrations.json',
'settings.json',
'user.json',
])
await runMigrations()
files = await fsReaddir(dir)
expect(files).toEqual(['app.json', 'migrations.json', 'windowParams.json'])
db.init(dir)
db.registerTransform('app', 'accounts', accountsTransform)
const accounts = await db.getKey('app', 'accounts')
expect(accounts.length).toBe(3)
expect(accounts[0].balance).toBeInstanceOf(BigNumber)
const windowParams = await db.getNamespace('windowParams')
expect(windowParams).toEqual({
MainWindow: {
positions: { x: 37, y: 37 },
dimensions: { width: 1526, height: 826 },
},
})
})
})
describe('with encryption', () => {
test('merging db files', async () => {
const dir = await extractMock('userdata_v1.0.5_mock-02-encrypted-accounts')
db.init(dir)
db.registerTransform('app', 'accounts', accountsTransform)
await runMigrations()
await db.setEncryptionKey('app', 'accounts', 'passw0rd')
const files = await fsReaddir(dir)
expect(files).toEqual(['app.json', 'migrations.json', 'windowParams.json'])
const accounts = await db.getKey('app', 'accounts')
expect(accounts.length).toBe(6)
expect(accounts[0].balance).toBeInstanceOf(BigNumber)
})
test('migrate password setting', async () => {
const dir = await extractMock('userdata_v1.0.5_mock-02-encrypted-accounts')
db.init(dir)
db.registerTransform('app', 'accounts', accountsTransform)
await runMigrations()
const legacyPasswordSettings = await db.getKey('app', 'settings.password')
expect(legacyPasswordSettings).toBeUndefined()
const hasPassword = await db.getKey('app', 'settings.hasPassword')
expect(hasPassword).toBe(true)
})
})
})
async function extractMock(mockName) {
const destDirectory = path.resolve(tmpDir, mockName)
const zipFilePath = path.resolve(__dirname, 'mocks', `${mockName}.zip`)
await rimraf(destDirectory)
await extractZip(zipFilePath, destDirectory)
return destDirectory
}
function extractZip(zipFilePath, destDirectory) {
return new Promise((resolve, reject) => {
const childProcess = spawn('unzip', [zipFilePath, '-d', destDirectory])
childProcess.on('close', resolve)
childProcess.on('error', reject)
})
}

BIN
src/migrations/mocks/userdata_v1.0.5_mock-01.zip

Binary file not shown.

BIN
src/migrations/mocks/userdata_v1.0.5_mock-02-encrypted-accounts.zip

Binary file not shown.

6
src/reducers/application.js

@ -2,8 +2,6 @@
import { handleActions, createAction } from 'redux-actions'
import { hasPassword } from 'reducers/settings'
export type ApplicationState = {
isLocked?: boolean,
}
@ -24,9 +22,7 @@ export const lock = createAction('APPLICATION_SET_DATA', () => ({ isLocked: true
// Selectors
export const isLocked = (state: Object) =>
// FIXME why!?
state.application.isLocked === undefined ? hasPassword(state) : state.application.isLocked
export const isLocked = (state: Object) => state.application.isLocked === true
// Exporting reducer

12
src/reducers/settings.js

@ -37,10 +37,7 @@ export type SettingsState = {
language: ?string,
region: ?string,
orderAccounts: string,
password: {
isEnabled: boolean,
value: string,
},
hasPassword: boolean,
selectedTimeRange: TimeRange,
marketIndicator: 'eastern' | 'western',
currenciesSettings: {
@ -67,10 +64,7 @@ const INITIAL_STATE: SettingsState = {
language: null,
region: null,
orderAccounts: 'balance|asc',
password: {
isEnabled: false,
value: '',
},
hasPassword: false,
selectedTimeRange: 'month',
marketIndicator: 'western',
currenciesSettings: {},
@ -140,7 +134,7 @@ export const storeSelector = (state: State): SettingsState => state.settings
export const settingsExportSelector = storeSelector
export const hasPassword = (state: State): boolean => state.settings.password.isEnabled
export const hasPasswordSelector = (state: State): boolean => state.settings.hasPassword === true
export const getCounterValueCode = (state: State) => state.settings.counterValue

10
src/renderer/events.js

@ -9,17 +9,17 @@
// events should all appear in the promise result / observer msgs as soon as they have this requestId
import 'commands'
import logger from 'logger'
import network from 'api/network'
import { ipcRenderer } from 'electron'
import debug from 'debug'
import network from 'api/network'
import logger from 'logger'
import db from 'helpers/db'
import { CHECK_UPDATE_DELAY, DISABLE_ACTIVITY_INDICATORS } from 'config/constants'
import { onSetDeviceBusy } from 'components/DeviceBusyIndicator'
import { onSetLibcoreBusy } from 'components/LibcoreBusyIndicator'
import { hasPassword } from 'reducers/settings'
import { lock } from 'reducers/application'
import { setUpdateStatus } from 'reducers/update'
import { addDevice, removeDevice, resetDevices } from 'actions/devices'
@ -84,7 +84,7 @@ export default ({ store }: { store: Object }) => {
syncDevices()
ipcRenderer.on('lock', () => {
if (hasPassword(store.getState())) {
if (db.hasEncryptionKey('app', 'accounts')) {
store.dispatch(lock())
}
})

22
src/renderer/init.js

@ -17,15 +17,18 @@ import { enableGlobalTab, disableGlobalTab, isGlobalTabEnabled } from 'config/gl
import { fetchAccounts } from 'actions/accounts'
import { fetchSettings } from 'actions/settings'
import { isLocked } from 'reducers/application'
import { lock } from 'reducers/application'
import { languageSelector, sentryLogsSelector } from 'reducers/settings'
import libcoreGetVersion from 'commands/libcoreGetVersion'
import resolveUserDataDirectory from 'helpers/resolveUserDataDirectory'
import db from 'helpers/db'
import dbMiddleware from 'middlewares/db'
import CounterValues from 'helpers/countervalues'
import hardReset from 'helpers/hardReset'
import { decodeAccountsModel, encodeAccountsModel } from 'reducers/accounts'
import sentry from 'sentry/browser'
import App from 'components/App'
import AppError from 'components/AppError'
@ -33,26 +36,28 @@ import AppError from 'components/AppError'
import 'styles/global'
const rootNode = document.getElementById('app')
const userDataDirectory = resolveUserDataDirectory()
const TAB_KEY = 9
db.init(userDataDirectory)
async function init() {
if (LEDGER_RESET_ALL) {
await hardReset()
}
await runMigrations()
// Init db with defaults if needed
db.init('settings', {})
db.init(userDataDirectory)
db.registerTransform('app', 'accounts', { get: decodeAccountsModel, set: encodeAccountsModel })
const history = createHistory()
const store = createStore({ history, dbMiddleware })
const settings = db.get('settings')
const settings = await db.getKey('app', 'settings')
store.dispatch(fetchSettings(settings))
const countervaluesData = db.get('countervalues')
const countervaluesData = await db.getKey('app', 'countervalues')
if (countervaluesData) {
store.dispatch(CounterValues.importAction(countervaluesData))
}
@ -66,7 +71,10 @@ async function init() {
// FIXME IMO init() really should only be for window. any other case is a hack!
const isMainWindow = remote.getCurrentWindow().name === 'MainWindow'
if (!isLocked(store.getState())) {
const isAccountsDecrypted = await db.hasBeenDecrypted('app', 'accounts')
if (!isAccountsDecrypted) {
store.dispatch(lock())
} else {
await store.dispatch(fetchAccounts())
}

5
src/sentry/browser.js

@ -4,8 +4,9 @@ import Raven from 'raven-js'
import user from 'helpers/user'
import install from './install'
export default (shouldSendCallback: () => boolean) => {
install(Raven, shouldSendCallback, user().id)
export default async (shouldSendCallback: () => boolean) => {
const u = await user()
install(Raven, shouldSendCallback, u.id)
}
export const captureException = (e: Error) => {

4
yarn.lock

@ -3554,10 +3554,6 @@ bcrypt-pbkdf@^1.0.0:
dependencies:
tweetnacl "^0.14.3"
bcryptjs@^2.4.3:
version "2.4.3"
resolved "https://registry.yarnpkg.com/bcryptjs/-/bcryptjs-2.4.3.tgz#9ab5627b93e60621ff7cdac5da9733027df1d0cb"
bech32@^1.1.2:
version "1.1.3"
resolved "https://registry.yarnpkg.com/bech32/-/bech32-1.1.3.tgz#bd47a8986bbb3eec34a56a097a84b8d3e9a2dfcd"

Loading…
Cancel
Save