mirror of
https://github.com/IT4Change/gradido.git
synced 2025-12-12 23:35:50 +00:00
Merge branch 'master' into 2179-bug-wrong-month-for-contribution-near-turn-of-month
This commit is contained in:
commit
1e4d922c88
13
CHANGELOG.md
13
CHANGELOG.md
@ -4,8 +4,21 @@ All notable changes to this project will be documented in this file. Dates are d
|
||||
|
||||
Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
|
||||
|
||||
#### [1.15.0](https://github.com/gradido/gradido/compare/1.14.1...1.15.0)
|
||||
|
||||
- fix(database): wrong balance and decay values [`#2423`](https://github.com/gradido/gradido/pull/2423)
|
||||
- fix(backend): wrong balance after transaction receive [`#2422`](https://github.com/gradido/gradido/pull/2422)
|
||||
- feat(other): feature gradido roadmap [`#2301`](https://github.com/gradido/gradido/pull/2301)
|
||||
- refactor(backend): new password encryption implementation [`#2353`](https://github.com/gradido/gradido/pull/2353)
|
||||
- refactor(admin): statistics in a table and on separate page in admin area [`#2399`](https://github.com/gradido/gradido/pull/2399)
|
||||
- feat(backend): 🍰 Email Templates [`#2163`](https://github.com/gradido/gradido/pull/2163)
|
||||
- fix(backend): timezone problems [`#2393`](https://github.com/gradido/gradido/pull/2393)
|
||||
|
||||
#### [1.14.1](https://github.com/gradido/gradido/compare/1.14.0...1.14.1)
|
||||
|
||||
> 14 November 2022
|
||||
|
||||
- chore(release): version 1.14.1 - hotfix [`#2391`](https://github.com/gradido/gradido/pull/2391)
|
||||
- fix(frontend): load contributionMessages is fixed [`#2390`](https://github.com/gradido/gradido/pull/2390)
|
||||
|
||||
#### [1.14.0](https://github.com/gradido/gradido/compare/1.13.3...1.14.0)
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"description": "Administraion Interface for Gradido",
|
||||
"main": "index.js",
|
||||
"author": "Moriz Wahl",
|
||||
"version": "1.14.1",
|
||||
"version": "1.15.0",
|
||||
"license": "Apache-2.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
CONFIG_VERSION=v11.2022-10-27
|
||||
CONFIG_VERSION=v12.2022-11-10
|
||||
|
||||
# Server
|
||||
PORT=4000
|
||||
@ -61,7 +61,8 @@ EVENT_PROTOCOL_DISABLED=false
|
||||
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
|
||||
# LOG_LEVEL=info
|
||||
|
||||
# DHT
|
||||
# if you set this value, the DHT hyperswarm will start to announce and listen
|
||||
# on an hash created from this tpoic
|
||||
# DHT_TOPIC=GRADIDO_HUB
|
||||
# Federation
|
||||
# if you set the value of FEDERATION_DHT_TOPIC, the DHT hyperswarm will start to announce and listen
|
||||
# on an hash created from this topic
|
||||
# FEDERATION_DHT_TOPIC=GRADIDO_HUB
|
||||
# FEDERATION_DHT_SEED=64ebcb0e3ad547848fef4197c6e2332f
|
||||
|
||||
@ -56,5 +56,6 @@ WEBHOOK_ELOPAGE_SECRET=$WEBHOOK_ELOPAGE_SECRET
|
||||
# EventProtocol
|
||||
EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED
|
||||
|
||||
# DHT
|
||||
DHT_TOPIC=$DHT_TOPIC
|
||||
# Federation
|
||||
FEDERATION_DHT_TOPIC=$FEDERATION_DHT_TOPIC
|
||||
FEDERATION_DHT_SEED=$FEDERATION_DHT_SEED
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido-backend",
|
||||
"version": "1.14.1",
|
||||
"version": "1.15.0",
|
||||
"description": "Gradido unified backend providing an API-Service for Gradido Transactions",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/gradido/gradido/backend",
|
||||
@ -19,13 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hyperswarm/dht": "^6.2.0",
|
||||
"@types/email-templates": "^10.0.1",
|
||||
"@types/i18n": "^0.13.4",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/lodash.clonedeep": "^4.5.6",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"apollo-server-express": "^2.25.2",
|
||||
"apollo-server-testing": "^2.25.2",
|
||||
"axios": "^0.21.1",
|
||||
"class-validator": "^0.13.1",
|
||||
"cors": "^2.8.5",
|
||||
@ -46,18 +40,23 @@
|
||||
"random-bigint": "^0.0.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"sodium-native": "^3.3.0",
|
||||
"ts-jest": "^27.0.5",
|
||||
"type-graphql": "^1.1.1",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/email-templates": "^10.0.1",
|
||||
"@types/express": "^4.17.12",
|
||||
"@types/faker": "^5.5.9",
|
||||
"@types/i18n": "^0.13.4",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/jsonwebtoken": "^8.5.2",
|
||||
"@types/lodash.clonedeep": "^4.5.6",
|
||||
"@types/node": "^16.10.3",
|
||||
"@types/nodemailer": "^6.4.4",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^4.28.0",
|
||||
"@typescript-eslint/parser": "^4.28.0",
|
||||
"apollo-server-testing": "^2.25.2",
|
||||
"eslint": "^7.29.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-config-standard": "^16.0.3",
|
||||
@ -66,8 +65,10 @@
|
||||
"eslint-plugin-prettier": "^3.4.0",
|
||||
"eslint-plugin-promise": "^5.1.0",
|
||||
"faker": "^5.5.3",
|
||||
"jest": "^27.2.4",
|
||||
"nodemon": "^2.0.7",
|
||||
"prettier": "^2.3.1",
|
||||
"ts-jest": "^27.0.5",
|
||||
"ts-node": "^10.0.0",
|
||||
"tsconfig-paths": "^3.14.0",
|
||||
"typescript": "^4.3.4"
|
||||
|
||||
@ -10,14 +10,14 @@ Decimal.set({
|
||||
})
|
||||
|
||||
const constants = {
|
||||
DB_VERSION: '0052-add_updated_at_to_contributions',
|
||||
DB_VERSION: '0054-recalculate_balance_and_decay',
|
||||
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
|
||||
LOG4JS_CONFIG: 'log4js-config.json',
|
||||
// default log level on production should be info
|
||||
LOG_LEVEL: process.env.LOG_LEVEL || 'info',
|
||||
CONFIG_VERSION: {
|
||||
DEFAULT: 'DEFAULT',
|
||||
EXPECTED: 'v11.2022-10-27',
|
||||
EXPECTED: 'v12.2022-11-10',
|
||||
CURRENT: '',
|
||||
},
|
||||
}
|
||||
@ -117,7 +117,8 @@ if (
|
||||
}
|
||||
|
||||
const federation = {
|
||||
DHT_TOPIC: process.env.DHT_TOPIC || null,
|
||||
FEDERATION_DHT_TOPIC: process.env.FEDERATION_DHT_TOPIC || null,
|
||||
FEDERATION_DHT_SEED: process.env.FEDERATION_DHT_SEED || null,
|
||||
}
|
||||
|
||||
const CONFIG = {
|
||||
|
||||
@ -4,11 +4,16 @@
|
||||
import DHT from '@hyperswarm/dht'
|
||||
// import { Connection } from '@dbTools/typeorm'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import CONFIG from '@/config'
|
||||
|
||||
function between(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min)
|
||||
}
|
||||
|
||||
const KEY_SECRET_SEEDBYTES = 32
|
||||
const getSeed = (): Buffer | null =>
|
||||
CONFIG.FEDERATION_DHT_SEED ? Buffer.alloc(KEY_SECRET_SEEDBYTES, CONFIG.FEDERATION_DHT_SEED) : null
|
||||
|
||||
const POLLTIME = 20000
|
||||
const SUCCESSTIME = 120000
|
||||
const ERRORTIME = 240000
|
||||
@ -27,8 +32,9 @@ export const startDHT = async (
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const TOPIC = DHT.hash(Buffer.from(topic))
|
||||
|
||||
const keyPair = DHT.keyPair()
|
||||
const keyPair = DHT.keyPair(getSeed())
|
||||
logger.info(`keyPairDHT: publicKey=${keyPair.publicKey.toString('hex')}`)
|
||||
logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex')}`)
|
||||
|
||||
const node = new DHT({ keyPair })
|
||||
|
||||
|
||||
12
backend/src/graphql/enum/PasswordEncryptionType.ts
Normal file
12
backend/src/graphql/enum/PasswordEncryptionType.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { registerEnumType } from 'type-graphql'
|
||||
|
||||
export enum PasswordEncryptionType {
|
||||
NO_PASSWORD = 0,
|
||||
EMAIL = 1,
|
||||
GRADIDO_ID = 2,
|
||||
}
|
||||
|
||||
registerEnumType(PasswordEncryptionType, {
|
||||
name: 'PasswordEncryptionType', // this one is mandatory
|
||||
description: 'Type of the password encryption', // this one is optional
|
||||
})
|
||||
@ -74,7 +74,10 @@ export class TransactionLinkResolver {
|
||||
const holdAvailableAmount = amount.minus(calculateDecay(amount, createdDate, validUntil).decay)
|
||||
|
||||
// validate amount
|
||||
await calculateBalance(user.id, holdAvailableAmount, createdDate)
|
||||
const sendBalance = await calculateBalance(user.id, holdAvailableAmount.mul(-1), createdDate)
|
||||
if (!sendBalance) {
|
||||
throw new Error("user hasn't enough GDD or amount is < 0")
|
||||
}
|
||||
|
||||
const transactionLink = dbTransactionLink.create()
|
||||
transactionLink.userId = user.id
|
||||
|
||||
@ -16,7 +16,7 @@ import { stephenHawking } from '@/seeds/users/stephen-hawking'
|
||||
import { EventProtocol } from '@entity/EventProtocol'
|
||||
import { Transaction } from '@entity/Transaction'
|
||||
import { User } from '@entity/User'
|
||||
import { cleanDB, resetToken, testEnvironment } from '@test/helpers'
|
||||
import { cleanDB, testEnvironment } from '@test/helpers'
|
||||
import { logger } from '@test/testSetup'
|
||||
import { GraphQLError } from 'graphql'
|
||||
import { findUserByEmail } from './UserResolver'
|
||||
@ -253,50 +253,21 @@ describe('send coins', () => {
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError(`User has not received any GDD yet`)],
|
||||
errors: [new GraphQLError(`user hasn't enough GDD or amount is < 0`)],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith(
|
||||
`No prior transaction found for user with id: ${user[1].id}`,
|
||||
`user hasn't enough GDD or amount is < 0 : balance=null`,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sending negative amount', () => {
|
||||
it('throws an error', async () => {
|
||||
jest.clearAllMocks()
|
||||
expect(
|
||||
await mutate({
|
||||
mutation: sendCoins,
|
||||
variables: {
|
||||
email: 'peter@lustig.de',
|
||||
amount: -50,
|
||||
memo: 'testing negative',
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError('Transaction amount must be greater than 0')],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith('Transaction amount must be greater than 0: -50')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has some GDD', () => {
|
||||
beforeAll(async () => {
|
||||
resetToken()
|
||||
|
||||
// login as bob again
|
||||
await query({ mutation: login, variables: bobData })
|
||||
|
||||
// create contribution as user bob
|
||||
const contribution = await mutate({
|
||||
mutation: createContribution,
|
||||
@ -316,6 +287,37 @@ describe('send coins', () => {
|
||||
await query({ mutation: login, variables: bobData })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanDB()
|
||||
})
|
||||
|
||||
/*
|
||||
describe('trying to send negative amount', () => {
|
||||
it('throws an error', async () => {
|
||||
expect(
|
||||
await mutate({
|
||||
mutation: sendCoins,
|
||||
variables: {
|
||||
email: 'peter@lustig.de',
|
||||
amount: -50,
|
||||
memo: 'testing negative',
|
||||
},
|
||||
}),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
errors: [new GraphQLError(`user hasn't enough GDD or amount is < 0`)],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('logs the error thrown', () => {
|
||||
expect(logger.error).toBeCalledWith(
|
||||
`user hasn't enough GDD or amount is < 0 : balance=null`,
|
||||
)
|
||||
})
|
||||
})
|
||||
*/
|
||||
|
||||
describe('good transaction', () => {
|
||||
it('sends the coins', async () => {
|
||||
expect(
|
||||
|
||||
@ -39,7 +39,6 @@ import { findUserByEmail } from './UserResolver'
|
||||
import { sendTransactionLinkRedeemedEmail } from '@/mailer/sendTransactionLinkRedeemed'
|
||||
import { Event, EventTransactionReceive, EventTransactionSend } from '@/event/Event'
|
||||
import { eventProtocol } from '@/event/EventProtocolEmitter'
|
||||
import { Decay } from '../model/Decay'
|
||||
|
||||
export const executeTransaction = async (
|
||||
amount: Decimal,
|
||||
@ -69,8 +68,17 @@ export const executeTransaction = async (
|
||||
|
||||
// validate amount
|
||||
const receivedCallDate = new Date()
|
||||
|
||||
const sendBalance = await calculateBalance(sender.id, amount, receivedCallDate, transactionLink)
|
||||
const sendBalance = await calculateBalance(
|
||||
sender.id,
|
||||
amount.mul(-1),
|
||||
receivedCallDate,
|
||||
transactionLink,
|
||||
)
|
||||
logger.debug(`calculated Balance=${sendBalance}`)
|
||||
if (!sendBalance) {
|
||||
logger.error(`user hasn't enough GDD or amount is < 0 : balance=${sendBalance}`)
|
||||
throw new Error("user hasn't enough GDD or amount is < 0")
|
||||
}
|
||||
|
||||
const queryRunner = getConnection().createQueryRunner()
|
||||
await queryRunner.connect()
|
||||
@ -100,24 +108,7 @@ export const executeTransaction = async (
|
||||
transactionReceive.userId = recipient.id
|
||||
transactionReceive.linkedUserId = sender.id
|
||||
transactionReceive.amount = amount
|
||||
|
||||
// state received balance
|
||||
let receiveBalance: {
|
||||
balance: Decimal
|
||||
decay: Decay
|
||||
lastTransactionId: number
|
||||
} | null
|
||||
|
||||
// try received balance
|
||||
try {
|
||||
receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
|
||||
} catch (e) {
|
||||
logger.info(
|
||||
`User with no transactions sent: ${recipient.id}, has received a transaction of ${amount} GDD from user: ${sender.id}`,
|
||||
)
|
||||
receiveBalance = null
|
||||
}
|
||||
|
||||
const receiveBalance = await calculateBalance(recipient.id, amount, receivedCallDate)
|
||||
transactionReceive.balance = receiveBalance ? receiveBalance.balance : amount
|
||||
transactionReceive.balanceDate = receivedCallDate
|
||||
transactionReceive.decay = receiveBalance ? receiveBalance.decay.decay : new Decimal(0)
|
||||
|
||||
@ -36,6 +36,9 @@ import { UserContact } from '@entity/UserContact'
|
||||
import { OptInType } from '../enum/OptInType'
|
||||
import { UserContactType } from '../enum/UserContactType'
|
||||
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
|
||||
import { encryptPassword } from '@/password/PasswordEncryptor'
|
||||
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
|
||||
import { SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
|
||||
|
||||
// import { klicktippSignIn } from '@/apis/KlicktippController'
|
||||
|
||||
@ -146,6 +149,7 @@ describe('UserResolver', () => {
|
||||
publisherId: 1234,
|
||||
referrerId: null,
|
||||
contributionLinkId: null,
|
||||
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
|
||||
},
|
||||
])
|
||||
const valUUID = validateUUID(user[0].gradidoID)
|
||||
@ -491,7 +495,8 @@ describe('UserResolver', () => {
|
||||
})
|
||||
|
||||
it('updates the password', () => {
|
||||
expect(newUser.password).toEqual('3917921995996627700')
|
||||
const encryptedPass = encryptPassword(newUser, 'Aa12345_')
|
||||
expect(newUser.password.toString()).toEqual(encryptedPass.toString())
|
||||
})
|
||||
|
||||
/*
|
||||
@ -1159,6 +1164,93 @@ describe('UserResolver', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('password encryption type', () => {
|
||||
describe('user just registered', () => {
|
||||
let bibi: User
|
||||
|
||||
it('has password type gradido id', async () => {
|
||||
const users = await User.find()
|
||||
bibi = users[1]
|
||||
|
||||
expect(bibi).toEqual(
|
||||
expect.objectContaining({
|
||||
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
|
||||
.readBigUInt64LE()
|
||||
.toString(),
|
||||
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user has encryption type email', () => {
|
||||
const variables = {
|
||||
email: 'bibi@bloxberg.de',
|
||||
password: 'Aa12345_',
|
||||
publisherId: 1234,
|
||||
}
|
||||
|
||||
let bibi: User
|
||||
beforeAll(async () => {
|
||||
const usercontact = await UserContact.findOneOrFail(
|
||||
{ email: 'bibi@bloxberg.de' },
|
||||
{ relations: ['user'] },
|
||||
)
|
||||
bibi = usercontact.user
|
||||
bibi.passwordEncryptionType = PasswordEncryptionType.EMAIL
|
||||
bibi.password = SecretKeyCryptographyCreateKey(
|
||||
'bibi@bloxberg.de',
|
||||
'Aa12345_',
|
||||
)[0].readBigUInt64LE()
|
||||
|
||||
await bibi.save()
|
||||
})
|
||||
|
||||
it('changes to gradidoID on login', async () => {
|
||||
await mutate({ mutation: login, variables: variables })
|
||||
|
||||
const usercontact = await UserContact.findOneOrFail(
|
||||
{ email: 'bibi@bloxberg.de' },
|
||||
{ relations: ['user'] },
|
||||
)
|
||||
bibi = usercontact.user
|
||||
|
||||
expect(bibi).toEqual(
|
||||
expect.objectContaining({
|
||||
firstName: 'Bibi',
|
||||
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
|
||||
.readBigUInt64LE()
|
||||
.toString(),
|
||||
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('can login after password change', async () => {
|
||||
resetToken()
|
||||
expect(await mutate({ mutation: login, variables: variables })).toEqual(
|
||||
expect.objectContaining({
|
||||
data: {
|
||||
login: {
|
||||
email: 'bibi@bloxberg.de',
|
||||
firstName: 'Bibi',
|
||||
hasElopage: false,
|
||||
id: expect.any(Number),
|
||||
isAdmin: null,
|
||||
klickTipp: {
|
||||
newsletterState: false,
|
||||
},
|
||||
language: 'de',
|
||||
lastName: 'Bloxberg',
|
||||
publisherId: 1234,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('printTimeDuration', () => {
|
||||
|
||||
@ -40,17 +40,15 @@ import { SearchAdminUsersResult } from '@model/AdminUser'
|
||||
import Paginated from '@arg/Paginated'
|
||||
import { Order } from '@enum/Order'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { isValidPassword, SecretKeyCryptographyCreateKey } from '@/password/EncryptorUtils'
|
||||
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
|
||||
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const sodium = require('sodium-native')
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const random = require('random-bigint')
|
||||
|
||||
// We will reuse this for changePassword
|
||||
const isPassword = (password: string): boolean => {
|
||||
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
|
||||
}
|
||||
|
||||
const LANGUAGES = ['de', 'en', 'es', 'fr', 'nl']
|
||||
const DEFAULT_LANGUAGE = 'de'
|
||||
const isLanguage = (language: string): boolean => {
|
||||
@ -107,48 +105,6 @@ const KeyPairEd25519Create = (passphrase: string[]): Buffer[] => {
|
||||
return [pubKey, privKey]
|
||||
}
|
||||
|
||||
const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
|
||||
logger.trace('SecretKeyCryptographyCreateKey...')
|
||||
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
|
||||
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
|
||||
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
|
||||
logger.error(
|
||||
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
|
||||
)
|
||||
throw new Error(
|
||||
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
|
||||
)
|
||||
}
|
||||
|
||||
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
|
||||
sodium.crypto_hash_sha512_init(state)
|
||||
sodium.crypto_hash_sha512_update(state, Buffer.from(salt))
|
||||
sodium.crypto_hash_sha512_update(state, configLoginAppSecret)
|
||||
const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
|
||||
sodium.crypto_hash_sha512_final(state, hash)
|
||||
|
||||
const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES)
|
||||
const opsLimit = 10
|
||||
const memLimit = 33554432
|
||||
const algo = 2
|
||||
sodium.crypto_pwhash(
|
||||
encryptionKey,
|
||||
Buffer.from(password),
|
||||
hash.slice(0, sodium.crypto_pwhash_SALTBYTES),
|
||||
opsLimit,
|
||||
memLimit,
|
||||
algo,
|
||||
)
|
||||
|
||||
const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES)
|
||||
sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
|
||||
|
||||
logger.debug(
|
||||
`SecretKeyCryptographyCreateKey...successful: encryptionKeyHash= ${encryptionKeyHash}, encryptionKey= ${encryptionKey}`,
|
||||
)
|
||||
return [encryptionKeyHash, encryptionKey]
|
||||
}
|
||||
|
||||
/*
|
||||
const getEmailHash = (email: string): Buffer => {
|
||||
logger.trace('getEmailHash...')
|
||||
@ -346,12 +302,17 @@ export class UserResolver {
|
||||
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
|
||||
throw new Error('User has no private or publicKey')
|
||||
}
|
||||
const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
|
||||
const loginUserPassword = BigInt(dbUser.password.toString())
|
||||
if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) {
|
||||
|
||||
if (!verifyPassword(dbUser, password)) {
|
||||
logger.error('The User has no valid credentials.')
|
||||
throw new Error('No user with this credentials')
|
||||
}
|
||||
|
||||
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
|
||||
dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
|
||||
dbUser.password = encryptPassword(dbUser, password)
|
||||
await dbUser.save()
|
||||
}
|
||||
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
|
||||
logger.addContext('user', dbUser.id)
|
||||
logger.debug('validation of login credentials successful...')
|
||||
@ -481,6 +442,7 @@ export class UserResolver {
|
||||
dbUser.lastName = lastName
|
||||
dbUser.language = language
|
||||
dbUser.publisherId = publisherId
|
||||
dbUser.passwordEncryptionType = PasswordEncryptionType.NO_PASSWORD
|
||||
dbUser.passphrase = passphrase.join(' ')
|
||||
logger.debug('new dbUser=' + dbUser)
|
||||
if (redeemCode) {
|
||||
@ -634,7 +596,7 @@ export class UserResolver {
|
||||
): Promise<boolean> {
|
||||
logger.info(`setPassword(${code}, ***)...`)
|
||||
// Validate Password
|
||||
if (!isPassword(password)) {
|
||||
if (!isValidPassword(password)) {
|
||||
logger.error('Password entered is lexically invalid')
|
||||
throw new Error(
|
||||
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
|
||||
@ -692,10 +654,11 @@ export class UserResolver {
|
||||
userContact.emailChecked = true
|
||||
|
||||
// Update Password
|
||||
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
|
||||
const passwordHash = SecretKeyCryptographyCreateKey(userContact.email, password) // return short and long hash
|
||||
const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key
|
||||
const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1])
|
||||
user.password = passwordHash[0].readBigUInt64LE() // using the shorthash
|
||||
user.password = encryptPassword(user, password)
|
||||
user.pubKey = keyPair[0]
|
||||
user.privKey = encryptedPrivkey
|
||||
logger.debug('User credentials updated ...')
|
||||
@ -801,7 +764,7 @@ export class UserResolver {
|
||||
|
||||
if (password && passwordNew) {
|
||||
// Validate Password
|
||||
if (!isPassword(passwordNew)) {
|
||||
if (!isValidPassword(passwordNew)) {
|
||||
logger.error('newPassword does not fullfil the rules')
|
||||
throw new Error(
|
||||
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
|
||||
@ -813,7 +776,7 @@ export class UserResolver {
|
||||
userEntity.emailContact.email,
|
||||
password,
|
||||
)
|
||||
if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) {
|
||||
if (!verifyPassword(userEntity, password)) {
|
||||
logger.error(`Old password is invalid`)
|
||||
throw new Error(`Old password is invalid`)
|
||||
}
|
||||
@ -829,7 +792,8 @@ export class UserResolver {
|
||||
logger.debug('PrivateKey encrypted...')
|
||||
|
||||
// Save new password hash and newly encrypted private key
|
||||
userEntity.password = newPasswordHash[0].readBigUInt64LE()
|
||||
userEntity.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
|
||||
userEntity.password = encryptPassword(userEntity, passwordNew)
|
||||
userEntity.privKey = encryptedPrivkey
|
||||
}
|
||||
|
||||
|
||||
@ -19,8 +19,14 @@ async function main() {
|
||||
})
|
||||
|
||||
// start DHT hyperswarm when DHT_TOPIC is set in .env
|
||||
if (CONFIG.DHT_TOPIC) {
|
||||
await startDHT(CONFIG.DHT_TOPIC) // con,
|
||||
if (CONFIG.FEDERATION_DHT_TOPIC) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`starting Federation on ${CONFIG.FEDERATION_DHT_TOPIC} ${
|
||||
CONFIG.FEDERATION_DHT_SEED ? 'with seed...' : 'without seed...'
|
||||
}`,
|
||||
)
|
||||
await startDHT(CONFIG.FEDERATION_DHT_TOPIC) // con,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
71
backend/src/password/EncryptorUtils.ts
Normal file
71
backend/src/password/EncryptorUtils.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import CONFIG from '@/config'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
import { User } from '@entity/User'
|
||||
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const sodium = require('sodium-native')
|
||||
|
||||
// We will reuse this for changePassword
|
||||
export const isValidPassword = (password: string): boolean => {
|
||||
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
|
||||
}
|
||||
|
||||
export const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
|
||||
logger.trace('SecretKeyCryptographyCreateKey...')
|
||||
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
|
||||
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
|
||||
if (configLoginServerKey.length !== sodium.crypto_shorthash_KEYBYTES) {
|
||||
logger.error(
|
||||
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
|
||||
)
|
||||
throw new Error(
|
||||
`ServerKey has an invalid size. The size must be ${sodium.crypto_shorthash_KEYBYTES} bytes.`,
|
||||
)
|
||||
}
|
||||
|
||||
const state = Buffer.alloc(sodium.crypto_hash_sha512_STATEBYTES)
|
||||
sodium.crypto_hash_sha512_init(state)
|
||||
sodium.crypto_hash_sha512_update(state, Buffer.from(salt))
|
||||
sodium.crypto_hash_sha512_update(state, configLoginAppSecret)
|
||||
const hash = Buffer.alloc(sodium.crypto_hash_sha512_BYTES)
|
||||
sodium.crypto_hash_sha512_final(state, hash)
|
||||
|
||||
const encryptionKey = Buffer.alloc(sodium.crypto_box_SEEDBYTES)
|
||||
const opsLimit = 10
|
||||
const memLimit = 33554432
|
||||
const algo = 2
|
||||
sodium.crypto_pwhash(
|
||||
encryptionKey,
|
||||
Buffer.from(password),
|
||||
hash.slice(0, sodium.crypto_pwhash_SALTBYTES),
|
||||
opsLimit,
|
||||
memLimit,
|
||||
algo,
|
||||
)
|
||||
|
||||
const encryptionKeyHash = Buffer.alloc(sodium.crypto_shorthash_BYTES)
|
||||
sodium.crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
|
||||
|
||||
return [encryptionKeyHash, encryptionKey]
|
||||
}
|
||||
|
||||
export const getUserCryptographicSalt = (dbUser: User): string => {
|
||||
switch (dbUser.passwordEncryptionType) {
|
||||
case PasswordEncryptionType.NO_PASSWORD: {
|
||||
logger.error('Password not set for user ' + dbUser.id)
|
||||
throw new Error('Password not set for user ' + dbUser.id) // user has no password
|
||||
}
|
||||
case PasswordEncryptionType.EMAIL: {
|
||||
return dbUser.emailContact.email
|
||||
break
|
||||
}
|
||||
case PasswordEncryptionType.GRADIDO_ID: {
|
||||
return dbUser.gradidoID
|
||||
break
|
||||
}
|
||||
default:
|
||||
logger.error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
|
||||
throw new Error(`Unknown password encryption type: ${dbUser.passwordEncryptionType}`)
|
||||
}
|
||||
}
|
||||
14
backend/src/password/PasswordEncryptor.ts
Normal file
14
backend/src/password/PasswordEncryptor.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { User } from '@entity/User'
|
||||
// import { logger } from '@test/testSetup' getting error "jest is not defined"
|
||||
import { getUserCryptographicSalt, SecretKeyCryptographyCreateKey } from './EncryptorUtils'
|
||||
|
||||
export const encryptPassword = (dbUser: User, password: string): bigint => {
|
||||
const salt = getUserCryptographicSalt(dbUser)
|
||||
const keyBuffer = SecretKeyCryptographyCreateKey(salt, password) // return short and long hash
|
||||
const passwordHash = keyBuffer[0].readBigUInt64LE()
|
||||
return passwordHash
|
||||
}
|
||||
|
||||
export const verifyPassword = (dbUser: User, password: string): boolean => {
|
||||
return dbUser.password.toString() === encryptPassword(dbUser, password).toString()
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
import { PasswordEncryptionType } from '@/graphql/enum/PasswordEncryptionType'
|
||||
import { SaveOptions, RemoveOptions } from '@dbTools/typeorm'
|
||||
import { User as dbUser } from '@entity/User'
|
||||
import { UserContact } from '@entity/UserContact'
|
||||
@ -26,6 +27,8 @@ const communityDbUser: dbUser = {
|
||||
isAdmin: null,
|
||||
publisherId: 0,
|
||||
passphrase: '',
|
||||
// default password encryption type
|
||||
passwordEncryptionType: PasswordEncryptionType.NO_PASSWORD,
|
||||
hasId: function (): boolean {
|
||||
throw new Error('Function not implemented.')
|
||||
},
|
||||
|
||||
@ -1,17 +1,5 @@
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
export const objectValuesToArray = (obj: { [x: string]: string }): Array<string> => {
|
||||
return Object.keys(obj).map(function (key) {
|
||||
return obj[key]
|
||||
})
|
||||
}
|
||||
|
||||
// to improve code readability, as String is needed, it is handled inside this utility function
|
||||
export const decimalAddition = (a: Decimal, b: Decimal): Decimal => {
|
||||
return a.add(b.toString())
|
||||
}
|
||||
|
||||
// to improve code readability, as String is needed, it is handled inside this utility function
|
||||
export const decimalSubtraction = (a: Decimal, b: Decimal): Decimal => {
|
||||
return a.minus(b.toString())
|
||||
}
|
||||
|
||||
@ -5,8 +5,6 @@ import { Decay } from '@model/Decay'
|
||||
import { getCustomRepository } from '@dbTools/typeorm'
|
||||
import { TransactionLinkRepository } from '@repository/TransactionLink'
|
||||
import { TransactionLink as dbTransactionLink } from '@entity/TransactionLink'
|
||||
import { decimalSubtraction, decimalAddition } from './utilities'
|
||||
import { backendLogger as logger } from '@/server/logger'
|
||||
|
||||
function isStringBoolean(value: string): boolean {
|
||||
const lowerValue = value.toLowerCase()
|
||||
@ -25,26 +23,13 @@ async function calculateBalance(
|
||||
amount: Decimal,
|
||||
time: Date,
|
||||
transactionLink?: dbTransactionLink | null,
|
||||
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number }> {
|
||||
// negative or empty amount should not be allowed
|
||||
if (amount.lessThanOrEqualTo(0)) {
|
||||
logger.error(`Transaction amount must be greater than 0: ${amount}`)
|
||||
throw new Error('Transaction amount must be greater than 0')
|
||||
}
|
||||
|
||||
// check if user has prior transactions
|
||||
): Promise<{ balance: Decimal; decay: Decay; lastTransactionId: number } | null> {
|
||||
const lastTransaction = await Transaction.findOne({ userId }, { order: { balanceDate: 'DESC' } })
|
||||
|
||||
if (!lastTransaction) {
|
||||
logger.error(`No prior transaction found for user with id: ${userId}`)
|
||||
throw new Error('User has not received any GDD yet')
|
||||
}
|
||||
if (!lastTransaction) return null
|
||||
|
||||
const decay = calculateDecay(lastTransaction.balance, lastTransaction.balanceDate, time)
|
||||
|
||||
// new balance is the old balance minus the amount used
|
||||
const balance = decimalSubtraction(decay.balance, amount)
|
||||
|
||||
const balance = decay.balance.add(amount.toString())
|
||||
const transactionLinkRepository = getCustomRepository(TransactionLinkRepository)
|
||||
const { sumHoldAvailableAmount } = await transactionLinkRepository.summary(userId, time)
|
||||
|
||||
@ -52,16 +37,11 @@ async function calculateBalance(
|
||||
// else we cannot redeem links which are more or equal to half of what an account actually owns
|
||||
const releasedLinkAmount = transactionLink ? transactionLink.holdAvailableAmount : new Decimal(0)
|
||||
|
||||
const availableBalance = decimalSubtraction(balance, sumHoldAvailableAmount)
|
||||
|
||||
if (decimalAddition(availableBalance, releasedLinkAmount).lessThan(0)) {
|
||||
logger.error(
|
||||
`Not enough funds for a transaction of ${amount} GDD, user with id: ${userId} has only ${balance} GDD available`,
|
||||
)
|
||||
throw new Error('Not enough funds for transaction')
|
||||
if (
|
||||
balance.minus(sumHoldAvailableAmount.toString()).plus(releasedLinkAmount.toString()).lessThan(0)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
logger.debug(`calculated Balance=${balance}`)
|
||||
return { balance, lastTransactionId: lastTransaction.id, decay }
|
||||
}
|
||||
|
||||
|
||||
@ -100,6 +100,8 @@ COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
|
||||
COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
|
||||
# Copy Mnemonic files
|
||||
COPY --from=build ${DOCKER_WORKDIR}/src/config/*.txt ./src/config/
|
||||
# Copy log folder
|
||||
COPY --from=build ${DOCKER_WORKDIR}/log ./log
|
||||
# Copy run scripts run/
|
||||
# COPY --from=build ${DOCKER_WORKDIR}/run ./run
|
||||
|
||||
|
||||
127
database/entity/0053-change_password_encryption/User.ts
Normal file
127
database/entity/0053-change_password_encryption/User.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
OneToOne,
|
||||
} from 'typeorm'
|
||||
import { Contribution } from '../Contribution'
|
||||
import { ContributionMessage } from '../ContributionMessage'
|
||||
import { UserContact } from '../UserContact'
|
||||
|
||||
@Entity('users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
|
||||
export class User extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('increment', { unsigned: true })
|
||||
id: number
|
||||
|
||||
@Column({
|
||||
name: 'gradido_id',
|
||||
length: 36,
|
||||
nullable: false,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
gradidoID: string
|
||||
|
||||
@Column({
|
||||
name: 'alias',
|
||||
length: 20,
|
||||
nullable: true,
|
||||
default: null,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
alias: string
|
||||
|
||||
@Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true })
|
||||
pubKey: Buffer
|
||||
|
||||
@Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true })
|
||||
privKey: Buffer
|
||||
|
||||
@Column({
|
||||
type: 'text',
|
||||
name: 'passphrase',
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
passphrase: string
|
||||
|
||||
@OneToOne(() => UserContact, (emailContact: UserContact) => emailContact.user)
|
||||
@JoinColumn({ name: 'email_id' })
|
||||
emailContact: UserContact
|
||||
|
||||
@Column({ name: 'email_id', type: 'int', unsigned: true, nullable: true, default: null })
|
||||
emailId: number | null
|
||||
|
||||
@Column({
|
||||
name: 'first_name',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
default: null,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
firstName: string
|
||||
|
||||
@Column({
|
||||
name: 'last_name',
|
||||
length: 255,
|
||||
nullable: true,
|
||||
default: null,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
lastName: string
|
||||
|
||||
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
|
||||
createdAt: Date
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
|
||||
deletedAt: Date | null
|
||||
|
||||
@Column({ type: 'bigint', default: 0, unsigned: true })
|
||||
password: BigInt
|
||||
|
||||
@Column({
|
||||
name: 'password_encryption_type',
|
||||
type: 'int',
|
||||
unsigned: true,
|
||||
nullable: false,
|
||||
default: 0,
|
||||
})
|
||||
passwordEncryptionType: number
|
||||
|
||||
@Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false })
|
||||
language: string
|
||||
|
||||
@Column({ name: 'is_admin', type: 'datetime', nullable: true, default: null })
|
||||
isAdmin: Date | null
|
||||
|
||||
@Column({ name: 'referrer_id', type: 'int', unsigned: true, nullable: true, default: null })
|
||||
referrerId?: number | null
|
||||
|
||||
@Column({
|
||||
name: 'contribution_link_id',
|
||||
type: 'int',
|
||||
unsigned: true,
|
||||
nullable: true,
|
||||
default: null,
|
||||
})
|
||||
contributionLinkId?: number | null
|
||||
|
||||
@Column({ name: 'publisher_id', default: 0 })
|
||||
publisherId: number
|
||||
|
||||
@OneToMany(() => Contribution, (contribution) => contribution.user)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
contributions?: Contribution[]
|
||||
|
||||
@OneToMany(() => ContributionMessage, (message) => message.user)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
messages?: ContributionMessage[]
|
||||
|
||||
@OneToMany(() => UserContact, (userContact: UserContact) => userContact.user)
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
userContacts?: UserContact[]
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import {
|
||||
BaseEntity,
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
DeleteDateColumn,
|
||||
OneToOne,
|
||||
} from 'typeorm'
|
||||
import { User } from './User'
|
||||
|
||||
@Entity('user_contacts', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' })
|
||||
export class UserContact extends BaseEntity {
|
||||
@PrimaryGeneratedColumn('increment', { unsigned: true })
|
||||
id: number
|
||||
|
||||
@Column({
|
||||
name: 'type',
|
||||
length: 100,
|
||||
nullable: true,
|
||||
default: null,
|
||||
collation: 'utf8mb4_unicode_ci',
|
||||
})
|
||||
type: string
|
||||
|
||||
@OneToOne(() => User, (user) => user.emailContact)
|
||||
user: User
|
||||
|
||||
@Column({ name: 'user_id', type: 'int', unsigned: true, nullable: false })
|
||||
userId: number
|
||||
|
||||
@Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' })
|
||||
email: string
|
||||
|
||||
@Column({ name: 'email_verification_code', type: 'bigint', unsigned: true, unique: true })
|
||||
emailVerificationCode: BigInt
|
||||
|
||||
@Column({ name: 'email_opt_in_type_id' })
|
||||
emailOptInTypeId: number
|
||||
|
||||
@Column({ name: 'email_resend_count' })
|
||||
emailResendCount: number
|
||||
|
||||
// @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true })
|
||||
// emailHash: Buffer
|
||||
|
||||
@Column({ name: 'email_checked', type: 'bool', nullable: false, default: false })
|
||||
emailChecked: boolean
|
||||
|
||||
@Column({ length: 255, unique: false, nullable: true, collation: 'utf8mb4_unicode_ci' })
|
||||
phone: string
|
||||
|
||||
@Column({ name: 'created_at', default: () => 'CURRENT_TIMESTAMP', nullable: false })
|
||||
createdAt: Date
|
||||
|
||||
@Column({ name: 'updated_at', nullable: true, default: null, type: 'datetime' })
|
||||
updatedAt: Date | null
|
||||
|
||||
@DeleteDateColumn({ name: 'deleted_at', nullable: true })
|
||||
deletedAt: Date | null
|
||||
}
|
||||
@ -1 +1 @@
|
||||
export { User } from './0049-add_user_contacts_table/User'
|
||||
export { User } from './0053-change_password_encryption/User'
|
||||
|
||||
@ -1 +1 @@
|
||||
export { UserContact } from './0049-add_user_contacts_table/UserContact'
|
||||
export { UserContact } from './0053-change_password_encryption/UserContact'
|
||||
|
||||
2
database/log/.gitignore
vendored
Normal file
2
database/log/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
24
database/migrations/0053-change_password_encryption.ts
Normal file
24
database/migrations/0053-change_password_encryption.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/* MIGRATION TO ADD ENCRYPTION TYPE TO PASSWORDS
|
||||
*
|
||||
* This migration adds and renames columns in the table `users`
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn('ALTER TABLE users RENAME COLUMN created TO created_at;')
|
||||
await queryFn('ALTER TABLE users RENAME COLUMN deletedAt TO deleted_at;')
|
||||
// alter table emp rename column emp_name to name
|
||||
await queryFn(
|
||||
'ALTER TABLE users ADD COLUMN password_encryption_type int(10) NOT NULL DEFAULT 0 AFTER password;',
|
||||
)
|
||||
await queryFn(`UPDATE users SET password_encryption_type = 1 WHERE id IN
|
||||
(SELECT user_id FROM user_contacts WHERE email_checked = 1)`)
|
||||
}
|
||||
|
||||
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
await queryFn('ALTER TABLE users RENAME COLUMN created_at TO created;')
|
||||
await queryFn('ALTER TABLE users RENAME COLUMN deleted_at TO deletedAt;')
|
||||
await queryFn('ALTER TABLE users DROP COLUMN password_encryption_type;')
|
||||
}
|
||||
160
database/migrations/0054-recalculate_balance_and_decay.ts
Normal file
160
database/migrations/0054-recalculate_balance_and_decay.ts
Normal file
@ -0,0 +1,160 @@
|
||||
/* MIGRATION TO FIX WRONG BALANCE
|
||||
*
|
||||
* Due to a bug in the code
|
||||
* the amount of a receive balance is substracted
|
||||
* from the previous balance instead of added.
|
||||
*
|
||||
* Therefore all balance and decay fields must
|
||||
* be recalculated
|
||||
*
|
||||
* WARNING: This Migration must be run in TZ=UTC
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import fs from 'fs'
|
||||
import Decimal from 'decimal.js-light'
|
||||
|
||||
// Set precision value
|
||||
Decimal.set({
|
||||
precision: 25,
|
||||
rounding: Decimal.ROUND_HALF_UP,
|
||||
})
|
||||
|
||||
const DECAY_START_TIME = new Date('2021-05-13 17:46:31') // GMT+0
|
||||
|
||||
interface Decay {
|
||||
balance: Decimal
|
||||
decay: Decimal | null
|
||||
start: Date | null
|
||||
end: Date | null
|
||||
duration: number | null
|
||||
}
|
||||
|
||||
export enum TransactionTypeId {
|
||||
CREATION = 1,
|
||||
SEND = 2,
|
||||
RECEIVE = 3,
|
||||
}
|
||||
|
||||
function decayFormula(value: Decimal, seconds: number): Decimal {
|
||||
return value.mul(new Decimal('0.99999997803504048973201202316767079413460520837376').pow(seconds))
|
||||
}
|
||||
|
||||
function calculateDecay(
|
||||
amount: Decimal,
|
||||
from: Date,
|
||||
to: Date,
|
||||
startBlock: Date = DECAY_START_TIME,
|
||||
): Decay {
|
||||
const fromMs = from.getTime()
|
||||
const toMs = to.getTime()
|
||||
const startBlockMs = startBlock.getTime()
|
||||
|
||||
if (toMs < fromMs) {
|
||||
throw new Error('to < from, reverse decay calculation is invalid')
|
||||
}
|
||||
|
||||
// Initialize with no decay
|
||||
const decay: Decay = {
|
||||
balance: amount,
|
||||
decay: null,
|
||||
start: null,
|
||||
end: null,
|
||||
duration: null,
|
||||
}
|
||||
|
||||
// decay started after end date; no decay
|
||||
if (startBlockMs > toMs) {
|
||||
return decay
|
||||
}
|
||||
// decay started before start date; decay for full duration
|
||||
if (startBlockMs < fromMs) {
|
||||
decay.start = from
|
||||
decay.duration = (toMs - fromMs) / 1000
|
||||
}
|
||||
// decay started between start and end date; decay from decay start till end date
|
||||
else {
|
||||
decay.start = startBlock
|
||||
decay.duration = (toMs - startBlockMs) / 1000
|
||||
}
|
||||
|
||||
decay.end = to
|
||||
decay.balance = decayFormula(amount, decay.duration)
|
||||
decay.decay = decay.balance.minus(amount)
|
||||
return decay
|
||||
}
|
||||
|
||||
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
|
||||
// Write log file
|
||||
const logFile = 'log/0054-recalculate_balance_and_decay.log.csv'
|
||||
await fs.writeFile(
|
||||
logFile,
|
||||
`email;first_name;last_name;affected_transactions;new_balance;new_decay;old_balance;old_decay;delta;\n`,
|
||||
(err) => {
|
||||
if (err) throw err
|
||||
},
|
||||
)
|
||||
|
||||
// Find all users & loop over them
|
||||
const users = await queryFn('SELECT user_id FROM transactions GROUP BY user_id;')
|
||||
for (let u = 0; u < users.length; u++) {
|
||||
const userId = users[u].user_id
|
||||
// find all transactions for a user
|
||||
const transactions = await queryFn(
|
||||
`SELECT *, CONVERT(balance, CHAR) as dec_balance, CONVERT(decay, CHAR) as dec_decay FROM transactions WHERE user_id = ${userId} ORDER BY balance_date ASC;`,
|
||||
)
|
||||
|
||||
let previous = null
|
||||
let affectedTransactions = 0
|
||||
let balance = new Decimal(0)
|
||||
for (let t = 0; t < transactions.length; t++) {
|
||||
const transaction = transactions[t]
|
||||
const decayStartDate = previous ? previous.balance_date : transaction.balance_date
|
||||
const amount = new Decimal(transaction.amount)
|
||||
const decay = calculateDecay(balance, decayStartDate, transaction.balance_date)
|
||||
balance = decay.balance.add(amount)
|
||||
|
||||
const userContact = await queryFn(
|
||||
`SELECT email, first_name, last_name FROM users LEFT JOIN user_contacts ON users.email_id = user_contacts.id WHERE users.id = ${userId}`,
|
||||
)
|
||||
const userEmail = userContact.length === 1 ? userContact[0].email : userId
|
||||
const userFirstName = userContact.length === 1 ? userContact[0].first_name : ''
|
||||
const userLastName = userContact.length === 1 ? userContact[0].last_name : ''
|
||||
|
||||
// Update if needed
|
||||
if (!balance.eq(transaction.dec_balance)) {
|
||||
await queryFn(`
|
||||
UPDATE transactions SET
|
||||
balance = ${balance},
|
||||
decay = ${decay.decay ? decay.decay : 0}
|
||||
WHERE id = ${transaction.id};
|
||||
`)
|
||||
affectedTransactions++
|
||||
|
||||
// Log on last entry
|
||||
if (t === transactions.length - 1) {
|
||||
fs.appendFile(
|
||||
logFile,
|
||||
`${userEmail};${userFirstName};${userLastName};${affectedTransactions};${balance};${
|
||||
decay.decay ? decay.decay : 0
|
||||
};${transaction.dec_balance};${transaction.dec_decay};${balance.sub(
|
||||
transaction.dec_balance,
|
||||
)};\n`,
|
||||
(err) => {
|
||||
if (err) throw err
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// previous
|
||||
previous = transaction
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido-database",
|
||||
"version": "1.14.1",
|
||||
"version": "1.15.0",
|
||||
"description": "Gradido Database Tool to execute database migrations",
|
||||
"main": "src/index.ts",
|
||||
"repository": "https://github.com/gradido/gradido/database",
|
||||
|
||||
@ -26,7 +26,7 @@ COMMUNITY_REDEEM_CONTRIBUTION_URL=https://stage1.gradido.net/redeem/CL-{code}
|
||||
COMMUNITY_DESCRIPTION="Gradido Development Stage1 Test Community"
|
||||
|
||||
# backend
|
||||
BACKEND_CONFIG_VERSION=v11.2022-10-27
|
||||
BACKEND_CONFIG_VERSION=v12.2022-11-10
|
||||
|
||||
JWT_EXPIRES_IN=10m
|
||||
GDT_API_URL=https://gdt.gradido.net
|
||||
@ -59,10 +59,11 @@ WEBHOOK_ELOPAGE_SECRET=secret
|
||||
# EventProtocol
|
||||
EVENT_PROTOCOL_DISABLED=false
|
||||
|
||||
## DHT
|
||||
## if you set this value, the DHT hyperswarm will start to announce and listen
|
||||
## on an hash created from this tpoic
|
||||
# DHT_TOPIC=GRADIDO_HUB
|
||||
# Federation
|
||||
# if you set the value of FEDERATION_DHT_TOPIC, the DHT hyperswarm will start to announce and listen
|
||||
# on an hash created from this topic
|
||||
# FEDERATION_DHT_TOPIC=GRADIDO_HUB
|
||||
# FEDERATION_DHT_SEED=64ebcb0e3ad547848fef4197c6e2332f
|
||||
|
||||
# database
|
||||
DATABASE_CONFIG_VERSION=v1.2022-03-18
|
||||
|
||||
167
docu/RoadMap_2022-2023.md
Normal file
167
docu/RoadMap_2022-2023.md
Normal file
@ -0,0 +1,167 @@
|
||||
# Roadmap 2022 / 2023
|
||||
|
||||
## unsortierte Sammlung von Themen
|
||||
|
||||
1. backend access layer
|
||||
|
||||
- Refactoring der Resolver-Klassen
|
||||
- Daten-Zugriffschicht zur Kapselung der DB-Schicht
|
||||
- Transfer-Datenmodel zum Austausch von Daten zwischen den Schichten
|
||||
- technisches Transaktion-Handling und Lösung von Deadlocks
|
||||
- Konzept in Arbeit
|
||||
2. capturing alias
|
||||
|
||||
- Konzept fertig
|
||||
- Änderungen in Register- und Login-Prozess
|
||||
3. Passwort-Verschlüsselung: Refactoring
|
||||
|
||||
- Konzept aufteilen in Ausbaustufen
|
||||
- Altlasten entsorgen
|
||||
- Versionierung/Typisierung der verwendeten Verschlüsselungslogik notwendig
|
||||
- DB-Migration auf encryptionType=EMAIL
|
||||
4. Passwort-Verschlüsselung: Login mit impliziter Neuverschlüsselung
|
||||
|
||||
* Logik der Passwortverschlüsselung auf GradidoID einführen
|
||||
* bei Login mit encryptionType=Email oder OneTime triggern einer Neuverschlüsselung per GradidoID
|
||||
* Unabhängigkeit von Email erzeugen
|
||||
* Änderung der User-Email ermöglichen
|
||||
5. Contribution-Categories
|
||||
|
||||
- Bewertung und Kategorisierung von Schöpfungen: Was hat Wer für Wen geleistet?
|
||||
- Regeln auf Categories ermöglichen
|
||||
- Konzept in Arbeit
|
||||
6. Statistics / Analysen
|
||||
7. Contribution-Link editieren
|
||||
8. User-Tagging
|
||||
|
||||
- Eine UserTag dient zur einfachen Gruppierung gleichgesinnter oder örtlich gebundener User
|
||||
- Motivation des User-Taggings: bilden kleinerer lokaler User-Gruppen und jeder kennt jeden
|
||||
- Einführung einer UserTaggings-Tabelle und eine User-UserTaggings-Zuordnungs-Tabelle
|
||||
- Ein Moderator kann im AdminInterface die Liste der UserTags pflegen
|
||||
|
||||
- neues TAG anlegen
|
||||
- vorhandenes TAG umbenennen
|
||||
- ein TAG löschen, sofern kein User mehr diesem TAG zugeordnet ist
|
||||
- Will ein User ein TAG zugeordnet werden, so kann dies nur ein Moderator im AdminInterface tun
|
||||
- Ein Moderator kann im AdminInterface
|
||||
|
||||
- ein TAG einem User zuordnen
|
||||
- ein TAG von einem User entfernen
|
||||
- wichtige UseCases:
|
||||
|
||||
- Zuordnung eines Users zu einem TAG durch einen Moderator
|
||||
- TAG spezifische Schöpfung
|
||||
- User muss für seinen Beitrag ein TAG auswählen können, dem er zuvor zugeordnet wurde
|
||||
- TAG-Moderator kann den Beitrag bestätigen, weil er den User mit dem TAG (persönlich) kennt
|
||||
9. User-Beziehungen und Favoritenverwaltung
|
||||
|
||||
- User-User-Zuordnung
|
||||
- aus Tx-Liste die aktuellen Favoriten ermitteln
|
||||
- Verwaltung von Zuordnungen
|
||||
- Auswahl
|
||||
- Berechtigungen
|
||||
- Gruppierung
|
||||
- Community-übergreifend
|
||||
- User-Beziehungen
|
||||
10. technische Ablösung der Email und Ersatz durch GradidoID
|
||||
|
||||
* APIs / Links / etc mit Email anpassen, so dass keine Email mehr verwendet wird
|
||||
* Email soll aber im Aussen für User optional noch verwendbar bleiben
|
||||
* Intern erfolgt aber auf jedenfall ein Mapping auf GradidoID egal ob per Email oder Alias angefragt wird
|
||||
11. Zeitzone
|
||||
|
||||
- User sieht immer seine Locale-Zeit und Monate
|
||||
- Admin sieht immer UTC-Zeit und Monate
|
||||
- wichtiges Kriterium für Schöpfung ist das TargetDate ( heißt in DB contributionDate)
|
||||
- Berechnung der möglichen Schöpfungen muss somit auf dem TargetDate der Schöpfung ermittelt werden! **(Ist-Zustand)**
|
||||
- Kann es vorkommen, dass das TargetDate der Contribution vor dem CreationDate der TX liegt? Ja
|
||||
- Beispiel: User in Tokyo Locale mit Offest +09:00
|
||||
|
||||
- aktiviert Contribution-Link mit Locale: 01.11.2022 07:00:00+09:00 = TargetDate = Zieldatum der Schöpfung
|
||||
- die Contribution wird gespeichert mit
|
||||
|
||||
- creationDate=31.10.2022 22:00:00 UTC
|
||||
- contributionDate=01.11.2022 07:00:00
|
||||
- (neu) clientRequestTime=01.11.2022 07:00:00+09:00
|
||||
- durch automatische Bestätigung und sofortiger Transaktion wird die TX gespeichert mit
|
||||
|
||||
- creationDate=31.10.2022 22:00:00 UTC
|
||||
- **zwingende Prüfung aller Requeste: auf -12h <= ClientRequestTime <= +12h**
|
||||
|
||||
- Prüfung auf Sommerzeiten und exotische Länder beachten
|
||||
-
|
||||
- zur Analyse und Problemverfolgung von Contributions immer original ClientRequestTime mit Offset in DB speichern
|
||||
- Beispiel für täglichen Contribution-Link während des Monats:
|
||||
|
||||
- 17.10.2022 22:00 +09:00 => 17.10.2022 UTC: 17.10.2022 13:00 UTC => 17.10.2022
|
||||
- 18.10.2022 02:00 +09:00 => 18.10.2022 UTC: 17.10.2022 17:00 UTC => 17.10.2022 !!!! darf nicht weil gleicher Tag !!!
|
||||
- Beispiel für täglichen Contribution-Link am Monatswechsel:
|
||||
|
||||
- 31.10.2022 22:00 +09:00 => 31.10.2022 UTC: 31.10.2022 15:00 UTC => 31.10.2022
|
||||
- 01.11.2022 07:00 +09:00 => 01.11.2022 UTC: 31.10.2022 22:00 UTC => 31.10.2022 !!!! darf nicht weil gleicher Tag !!!
|
||||
12. Layout
|
||||
13. Lastschriften-Link
|
||||
14. Registrierung mit Redeem-Link:
|
||||
|
||||
* bei inaktivem Konto, sprich bisher noch keine Email-Bestätigung, keine Buchung möglich
|
||||
* somit speichern des Links zusammen mit OptIn-Code
|
||||
* damit kann in einem Resend der ConfirmationEmail der Link auch korrekt wieder mitgeliefert werden
|
||||
15. Manuelle User-Registrierung für Admin
|
||||
|
||||
- soll am 10.12.2022 für den Tag bei den Galliern produktiv sein
|
||||
16. Dezentralisierung / Federation
|
||||
|
||||
- Hyperswarm
|
||||
|
||||
- funktioniert schon im Prototyp
|
||||
- alle Instanzen finden sich gegenseitig
|
||||
- ToDo:
|
||||
- Infos aus HyperSwarm in der Community speichern
|
||||
- Prüfung ob neue mir noch unbekannte Community hinzugekommen ist?
|
||||
- Triggern der Authentifizierungs- und Autorisierungs-Handshake für neue Community
|
||||
- Authentifizierungs- und Autorisierungs-Handshake
|
||||
- Inter-Community-Communication
|
||||
- **ToDos**:
|
||||
|
||||
- DB-Migration für Community-Tabelle, User-Community-Zuordnungen, UserRights-Tabelle
|
||||
- Berechtigungen für Communities
|
||||
- Register- und Login-Prozess für Community-Anmeldung anpassen
|
||||
|
||||
- Auswahl-Box einer Community
|
||||
- createUser mit Zuordnung zur ausgewählten Community
|
||||
- Schöpfungsprozess auf angemeldete Community anpassen
|
||||
|
||||
- "Beitrag einreichen"-Dialog auf angemeldete Community anpassen
|
||||
- "meine Beiträge zum Gemeinwohl" mit Filter auf angemeldete Community anpassen
|
||||
- "Gemeinschaft"-Dialog auf angemeldete Community anpassen
|
||||
- "Mein Profil"-Dialog auf Communities anpassen
|
||||
|
||||
- Umzug-Service in andere Community
|
||||
- Löschen der Mitgliedschaft zu angemeldeter Community (Deaktivierung der Zuordnung "User-Community")
|
||||
- "Senden"-Dialog mit Community-Auswahl
|
||||
- "Transaktion"-Dialog mit Filter auf angemeldeter Community
|
||||
- AdminInterface auf angemeldete Community anpassen
|
||||
|
||||
- "Übersicht"-Dialog mit Filter auf angemeldete Community
|
||||
- "Nutzersuche"-Dialog mit Filter auf angemeldete Community
|
||||
- "Mehrfachschöpfung"-Dialog mit Filter auf angemeldete Comunity
|
||||
- Subject/Texte/Footer/... der Email-Benachrichtigungen auf angemeldete Community anpassen
|
||||
|
||||
## Priorisierung
|
||||
|
||||
1. Contribution-Link editieren (vlt schon im vorherigen Bugfix-Release Ende Okt. 2022 fertig)
|
||||
2. Passwort-Verschlüsselung: Refactoring **Konzeption fertig!!**!
|
||||
3. Manuelle User-Registrierung für Admin (10.12.2022) **Konzeption ongoing!!**!
|
||||
4. Passwort-Verschlüsselung: implizite Login-Neuverschlüsselung **Konzeption fertig!!**!
|
||||
5. Layout
|
||||
6. Zeitzone
|
||||
7. Dezentralisierung / Federation
|
||||
8. capturing alias **Konzeption fertig!!**!
|
||||
9. Registrierung mit Redeem-Link: bei inaktivem Konto keine Buchung möglich
|
||||
10. Subgruppierung / User-Tagging (einfacher Ansatz)
|
||||
11. backend access layer
|
||||
12. technische Ablösung der Email und Ersatz durch GradidoID
|
||||
13. User-Beziehungen und Favoritenverwaltung
|
||||
14. Lastschriften-Link
|
||||
15. Contribution-Categories
|
||||
16. Statistics / Analysen
|
||||
60
docu/graphics/RoadMap2022-2023.drawio
Normal file
60
docu/graphics/RoadMap2022-2023.drawio
Normal file
@ -0,0 +1,60 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<diagram id="CdUoMVivL2xThNJutTjM" name="Seite-1">
|
||||
<mxGraphModel dx="1022" dy="800" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2336" pageHeight="1654" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="14" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="2" target="7">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="160" y="100"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="2" value="capturing alias" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="40" width="240" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="Manuelle User-Registrierung für Admin (10.12.2022)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="200" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="Zeitzone" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="280" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="User-Beziehungen und Favoritenverwaltung" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="360" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="Layout" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="440" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="15" style="edgeStyle=none;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontSize=16;" edge="1" parent="1" source="7" target="12">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="440" y="140"/>
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="Passwort-Verschlüsselung" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="320" y="80" width="240" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="Subgruppierung / Subcommunities" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="520" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="9" value="Contribution-Categories" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="600" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="10" value="backend access layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="680" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="11" value="Statistics / Analysen" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="760" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="12" value="Ablösung der Email und Ersatz durch GradidoID" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="600" y="120" width="360" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="13" value="Dezentralisierung / Federation" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;gradientColor=#b3b3b3;strokeColor=#666666;fontSize=16;" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="840" width="440" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
BIN
docu/graphics/RoadMap2022-2023.png
Normal file
BIN
docu/graphics/RoadMap2022-2023.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bootstrap-vue-gradido-wallet",
|
||||
"version": "1.14.1",
|
||||
"version": "1.15.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "node run/server.js",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gradido",
|
||||
"version": "1.14.1",
|
||||
"version": "1.15.0",
|
||||
"description": "Gradido",
|
||||
"main": "index.js",
|
||||
"repository": "git@github.com:gradido/gradido.git",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user