Merge branch 'master' into 2352-feat-user-story-user-authentication-reset-password

This commit is contained in:
mahula 2023-02-03 10:59:04 +01:00 committed by GitHub
commit feb393aff5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 6944 additions and 245 deletions

98
.github/workflows/test_dht-node.yml vendored Normal file
View File

@ -0,0 +1,98 @@
name: gradido test_dht-node CI
on: push
jobs:
##############################################################################
# JOB: DOCKER BUILD TEST #####################################################
##############################################################################
build:
name: Docker Build Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Build `test` image
run: |
docker build --target test -t "gradido/dht-node:test" -f dht-node/Dockerfile .
docker save "gradido/dht-node:test" > /tmp/dht-node.tar
- name: Upload Artifact
uses: actions/upload-artifact@v3
with:
name: docker-dht-node-test
path: /tmp/dht-node.tar
##############################################################################
# JOB: LINT ##################################################################
##############################################################################
lint:
name: Lint
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker Image
uses: actions/download-artifact@v3
with:
name: docker-dht-node-test
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/dht-node.tar
- name: Lint
run: docker run --rm gradido/dht-node:test yarn run lint
##############################################################################
# JOB: UNIT TEST #############################################################
##############################################################################
unit_test:
name: Unit tests
runs-on: ubuntu-latest
needs: [build]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Download Docker Image
uses: actions/download-artifact@v3
with:
name: docker-dht-node-test
path: /tmp
- name: Load Docker Image
run: docker load < /tmp/dht-node.tar
- name: docker-compose mariadb
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mariadb
- name: Sleep for 30 seconds
run: sleep 30s
shell: bash
- name: docker-compose database
run: docker-compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
- name: Sleep for 30 seconds
run: sleep 30s
shell: bash
#- name: Unit tests
# run: cd database && yarn && yarn build && cd ../dht-node && yarn && yarn test
- name: Unit tests
run: |
docker run --env NODE_ENV=test --env DB_HOST=mariadb --network gradido_internal-net -v ~/coverage:/app/coverage --rm gradido/dht-node:test yarn run test
cp -r ~/coverage ./coverage
- name: Coverage check
uses: webcraftmedia/coverage-check-action@master
with:
report_name: Coverage dht-node
type: lcov
#result_path: ./dht-node/coverage/lcov.info
result_path: ./coverage/lcov.info
min_coverage: 79
token: ${{ github.token }}

View File

@ -4,6 +4,7 @@ import path from 'path'
import { createTransport } from 'nodemailer'
import Email from 'email-templates'
import i18n from 'i18n'
import LogError from '@/server/LogError'
export const sendEmailTranslated = async (params: {
receiver: {
@ -73,8 +74,7 @@ export const sendEmailTranslated = async (params: {
logger.info('Result: ', result)
})
.catch((error: unknown) => {
logger.error('Error sending notification email: ', error)
throw new Error('Error sending notification email!')
throw new LogError('Error sending notification email', error)
})
i18n.setLocale(rememberLocaleToRestore)

View File

@ -45,6 +45,7 @@ import { Transaction as DbTransaction } from '@entity/Transaction'
import { User } from '@entity/User'
import { EventProtocolType } from '@/event/EventProtocolType'
import { logger, i18n as localization } from '@test/testSetup'
import { UserInputError } from 'apollo-server-express'
// mock account activation email to avoid console spam
// mock account activation email to avoid console spam
@ -681,7 +682,7 @@ describe('ContributionResolver', () => {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
statusFilter: null,
},
}),
).resolves.toEqual(
@ -718,7 +719,76 @@ describe('ContributionResolver', () => {
resetToken()
})
it('returns allCreation', async () => {
it('throws an error with "NOT_VALID" in statusFilter', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['NOT_VALID'],
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new UserInputError(
'Variable "$statusFilter" got invalid value "NOT_VALID" at "statusFilter[0]"; Value "NOT_VALID" does not exist in "ContributionStatus" enum.',
),
],
}),
)
})
it('throws an error with a null in statusFilter', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: [null],
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new UserInputError(
'Variable "$statusFilter" got invalid value null at "statusFilter[0]"; Expected non-nullable type "ContributionStatus!" not to be null.',
),
],
}),
)
})
it('throws an error with null and "NOT_VALID" in statusFilter', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: [null, 'NOT_VALID'],
},
}),
).resolves.toEqual(
expect.objectContaining({
errors: [
new UserInputError(
'Variable "$statusFilter" got invalid value null at "statusFilter[0]"; Expected non-nullable type "ContributionStatus!" not to be null.',
),
new UserInputError(
'Variable "$statusFilter" got invalid value "NOT_VALID" at "statusFilter[1]"; Value "NOT_VALID" does not exist in "ContributionStatus" enum.',
),
],
}),
)
})
it('returns all contributions without statusFilter', async () => {
await expect(
query({
query: listAllContributions,
@ -726,7 +796,6 @@ describe('ContributionResolver', () => {
currentPage: 1,
pageSize: 25,
order: 'DESC',
filterConfirmed: false,
},
}),
).resolves.toEqual(
@ -737,11 +806,301 @@ describe('ContributionResolver', () => {
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all contributions for statusFilter = null', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: null,
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all contributions for statusFilter = []', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: [],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all CONFIRMED contributions', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['CONFIRMED'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 1,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.not.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all PENDING contributions', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['PENDING'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 1,
contributionList: expect.arrayContaining([
expect.not.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all IN_PROGRESS Creation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['IN_PROGRESS'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 0,
contributionList: expect.not.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all DENIED Creation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['DENIED'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 0,
contributionList: expect.not.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all DELETED Creation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['DELETED'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 0,
contributionList: expect.not.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),
]),
},
},
}),
)
})
it('returns all CONFIRMED and PENDING Creation', async () => {
await expect(
query({
query: listAllContributions,
variables: {
currentPage: 1,
pageSize: 25,
order: 'DESC',
statusFilter: ['CONFIRMED', 'PENDING'],
},
}),
).resolves.toEqual(
expect.objectContaining({
data: {
listAllContributions: {
contributionCount: 2,
contributionList: expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
state: 'CONFIRMED',
memo: 'Herzlich Willkommen bei Gradido!',
amount: '1000',
}),
expect.objectContaining({
id: expect.any(Number),
state: 'PENDING',
memo: 'Test env contribution',
amount: '100',
}),

View File

@ -179,12 +179,23 @@ export class ContributionResolver {
async listAllContributions(
@Args()
{ currentPage = 1, pageSize = 5, order = Order.DESC }: Paginated,
@Arg('statusFilter', () => [ContributionStatus], { nullable: true })
statusFilter?: ContributionStatus[],
): Promise<ContributionListResult> {
const where: {
contributionStatus?: FindOperator<string> | null
} = {}
if (statusFilter && statusFilter.length) {
where.contributionStatus = In(statusFilter)
}
const [dbContributions, count] = await getConnection()
.createQueryBuilder()
.select('c')
.from(DbContribution, 'c')
.innerJoinAndSelect('c.user', 'u')
.where(where)
.orderBy('c.createdAt', order)
.limit(pageSize)
.offset((currentPage - 1) * pageSize)

View File

@ -75,7 +75,7 @@ describe('EmailOptinCodes', () => {
query({ query: queryOptIn, variables: { optIn: optinCode } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email was sent more than 24 hours ago')],
errors: [new GraphQLError('Email was sent more than 24 hours ago')],
})
})
@ -84,7 +84,7 @@ describe('EmailOptinCodes', () => {
mutate({ mutation: setPassword, variables: { code: optinCode, password: 'Aa12345_' } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email was sent more than 24 hours ago')],
errors: [new GraphQLError('Email was sent more than 24 hours ago')],
})
})
})
@ -96,7 +96,7 @@ describe('EmailOptinCodes', () => {
mutate({ mutation: forgotPassword, variables: { email: 'peter@lustig.de' } }),
).resolves.toMatchObject({
data: null,
errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')],
errors: [new GraphQLError('Email already sent less than 10 minutes ago')],
})
})

View File

@ -89,7 +89,7 @@ describe('send coins', () => {
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`UserContact with email=wrong@email.com does not exists`)
expect(logger.error).toBeCalledWith('No user with this credentials', 'wrong@email.com')
})
describe('deleted recipient', () => {

View File

@ -549,7 +549,9 @@ describe('UserResolver', () => {
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Password entered is lexically invalid')
expect(logger.error).toBeCalledWith(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
})
})
@ -606,9 +608,7 @@ describe('UserResolver', () => {
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(
'UserContact with email=bibi@bloxberg.de does not exists',
)
expect(logger.error).toBeCalledWith('No user with this credentials', variables.email)
})
})
@ -668,7 +668,112 @@ describe('UserResolver', () => {
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The User has no valid credentials.')
expect(logger.error).toBeCalledWith('No user with this credentials', variables.email)
})
})
describe('user is in database but deleted', () => {
beforeAll(async () => {
jest.clearAllMocks()
await userFactory(testEnv, stephenHawking)
const variables = {
email: stephenHawking.email,
password: 'Aa12345_',
publisherId: 1234,
}
result = await mutate({ mutation: login, variables })
})
afterAll(async () => {
await cleanDB()
})
it('returns an error', () => {
expect(result).toEqual(
expect.objectContaining({
errors: [
new GraphQLError('This user was permanently deleted. Contact support for questions'),
],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'This user was permanently deleted. Contact support for questions',
expect.objectContaining({
firstName: stephenHawking.firstName,
lastName: stephenHawking.lastName,
}),
)
})
})
describe('user is in database but email not confirmed', () => {
beforeAll(async () => {
jest.clearAllMocks()
await userFactory(testEnv, garrickOllivander)
const variables = {
email: garrickOllivander.email,
password: 'Aa12345_',
publisherId: 1234,
}
result = await mutate({ mutation: login, variables })
})
afterAll(async () => {
await cleanDB()
})
it('returns an error', () => {
expect(result).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The Users email is not validate yet')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The Users email is not validate yet',
expect.objectContaining({
firstName: garrickOllivander.firstName,
lastName: garrickOllivander.lastName,
}),
)
})
})
describe.skip('user is in database but password is not set', () => {
beforeAll(async () => {
jest.clearAllMocks()
// TODO: we need an user without password set
const user = await userFactory(testEnv, bibiBloxberg)
user.password = BigInt(0)
await user.save()
result = await mutate({ mutation: login, variables })
})
afterAll(async () => {
await cleanDB()
})
it('returns an error', () => {
expect(result).toEqual(
expect.objectContaining({
errors: [new GraphQLError('The User has not set a password yet')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(
'The User has not set a password yet',
expect.objectContaining({
firstName: bibiBloxberg.firstName,
lastName: bibiBloxberg.lastName,
}),
)
})
})
})
@ -828,9 +933,9 @@ describe('UserResolver', () => {
expect.objectContaining({
errors: [
new GraphQLError(
`email already sent less than ${printTimeDuration(
`Email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)} ago`,
),
],
}),
@ -870,13 +975,13 @@ describe('UserResolver', () => {
CONFIG.EMAIL_CODE_REQUEST_TIME = emailCodeRequestTime
await expect(mutate({ mutation: forgotPassword, variables })).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('email already sent less than 10 minutes minutes ago')],
errors: [new GraphQLError('Email already sent less than 10 minutes ago')],
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`email already sent less than 10 minutes minutes ago`)
expect(logger.error).toBeCalledWith(`Email already sent less than 10 minutes ago`)
})
})
})
@ -1001,13 +1106,13 @@ describe('UserResolver', () => {
}),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`"not-valid" isn't a valid language`)],
errors: [new GraphQLError('Given language is not a valid language')],
}),
)
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith(`"not-valid" isn't a valid language`)
expect(logger.error).toBeCalledWith('Given language is not a valid language', 'not-valid')
})
})
@ -1058,7 +1163,9 @@ describe('UserResolver', () => {
})
it('logs the error found', () => {
expect(logger.error).toBeCalledWith('newPassword does not fullfil the rules')
expect(logger.error).toBeCalledWith(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
})
})
@ -1116,7 +1223,9 @@ describe('UserResolver', () => {
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('The User has no valid credentials.')
expect(logger.error).toBeCalledWith(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
})
})
})
@ -1322,13 +1431,13 @@ describe('UserResolver', () => {
mutate({ mutation: setUserRole, variables: { userId: admin.id + 1, isAdmin: true } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
errors: [new GraphQLError('Could not find user with given ID')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
expect(logger.error).toBeCalledWith('Could not find user with given ID', admin.id + 1)
})
})
@ -1379,12 +1488,12 @@ describe('UserResolver', () => {
mutate({ mutation: setUserRole, variables: { userId: admin.id, isAdmin: false } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Administrator can not change his own role!')],
errors: [new GraphQLError('Administrator can not change his own role')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Administrator can not change his own role!')
expect(logger.error).toBeCalledWith('Administrator can not change his own role')
})
})
@ -1400,13 +1509,13 @@ describe('UserResolver', () => {
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: true } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('User is already admin!')],
errors: [new GraphQLError('User is already admin')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already admin!')
expect(logger.error).toBeCalledWith('User is already admin')
})
})
@ -1421,13 +1530,13 @@ describe('UserResolver', () => {
mutate({ mutation: setUserRole, variables: { userId: user.id, isAdmin: false } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('User is already a usual user!')],
errors: [new GraphQLError('User is already an usual user')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('User is already a usual user!')
expect(logger.error).toBeCalledWith('User is already an usual user')
})
})
})
@ -1494,13 +1603,13 @@ describe('UserResolver', () => {
mutate({ mutation: deleteUser, variables: { userId: admin.id + 1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
errors: [new GraphQLError('Could not find user with given ID')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
expect(logger.error).toBeCalledWith('Could not find user with given ID', admin.id + 1)
})
})
@ -1511,13 +1620,13 @@ describe('UserResolver', () => {
mutate({ mutation: deleteUser, variables: { userId: admin.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError('Moderator can not delete his own account!')],
errors: [new GraphQLError('Moderator can not delete his own account')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith('Moderator can not delete his own account!')
expect(logger.error).toBeCalledWith('Moderator can not delete his own account')
})
})
@ -1545,13 +1654,13 @@ describe('UserResolver', () => {
mutate({ mutation: deleteUser, variables: { userId: user.id } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${user.id}`)],
errors: [new GraphQLError('Could not find user with given ID')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${user.id}`)
expect(logger.error).toBeCalledWith('Could not find user with given ID', user.id)
})
})
})
@ -1617,13 +1726,13 @@ describe('UserResolver', () => {
mutate({ mutation: unDeleteUser, variables: { userId: admin.id + 1 } }),
).resolves.toEqual(
expect.objectContaining({
errors: [new GraphQLError(`Could not find user with userId: ${admin.id + 1}`)],
errors: [new GraphQLError('Could not find user with given ID')],
}),
)
})
it('logs the error thrown', () => {
expect(logger.error).toBeCalledWith(`Could not find user with userId: ${admin.id + 1}`)
expect(logger.error).toBeCalledWith('Could not find user with given ID', admin.id + 1)
})
})

View File

@ -63,6 +63,7 @@ import { isValidPassword } from '@/password/EncryptorUtils'
import { FULL_CREATION_AVAILABLE } from './const/const'
import { encryptPassword, verifyPassword } from '@/password/PasswordEncryptor'
import { PasswordEncryptionType } from '../enum/PasswordEncryptionType'
import LogError from '@/server/LogError'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
@ -134,22 +135,19 @@ export class UserResolver {
email = email.trim().toLowerCase()
const dbUser = await findUserByEmail(email)
if (dbUser.deletedAt) {
logger.error('The User was permanently deleted in database.')
throw new Error('This user was permanently deleted. Contact support for questions.')
throw new LogError('This user was permanently deleted. Contact support for questions', dbUser)
}
if (!dbUser.emailContact.emailChecked) {
logger.error('The Users email is not validate yet.')
throw new Error('User email not validated')
throw new LogError('The Users email is not validate yet', dbUser)
}
// TODO: at least in test this does not work since `dbUser.password = 0` and `BigInto(0) = 0n`
if (dbUser.password === BigInt(0)) {
logger.error('The User has not set a password yet.')
// 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 password set yet')
throw new LogError('The User has not set a password yet', dbUser)
}
if (!verifyPassword(dbUser, password)) {
logger.error('The User has no valid credentials.')
throw new Error('No user with this credentials')
throw new LogError('No user with this credentials', dbUser)
}
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
@ -309,30 +307,19 @@ export class UserResolver {
await queryRunner.startTransaction('REPEATABLE READ')
try {
dbUser = await queryRunner.manager.save(dbUser).catch((error) => {
logger.error('Error while saving dbUser', error)
throw new Error('error saving user')
throw new LogError('Error while saving dbUser', error)
})
let emailContact = newEmailContact(email, dbUser.id)
emailContact = await queryRunner.manager.save(emailContact).catch((error) => {
logger.error('Error while saving emailContact', error)
throw new Error('error saving email user contact')
throw new LogError('Error while saving user email contact', error)
})
dbUser.emailContact = emailContact
dbUser.emailId = emailContact.id
await queryRunner.manager.save(dbUser).catch((error) => {
logger.error('Error while updating dbUser', error)
throw new Error('error updating user')
throw new LogError('Error while updating dbUser', error)
})
/*
const emailOptIn = newEmailOptIn(dbUser.id)
await queryRunner.manager.save(emailOptIn).catch((error) => {
logger.error('Error while saving emailOptIn', error)
throw new Error('error saving email opt in')
})
*/
const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace(
/{optin}/g,
emailContact.emailVerificationCode.toString(),
@ -358,9 +345,8 @@ export class UserResolver {
await queryRunner.commitTransaction()
logger.addContext('user', dbUser.id)
} catch (e) {
logger.error(`error during create user with ${e}`)
await queryRunner.rollbackTransaction()
throw e
throw new LogError('Error creating user', e)
} finally {
await queryRunner.release()
}
@ -392,15 +378,8 @@ export class UserResolver {
}
if (!canEmailResend(user.emailContact.updatedAt || user.emailContact.createdAt)) {
logger.error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
)
throw new Error(
`email already sent less than ${printTimeDuration(
CONFIG.EMAIL_CODE_REQUEST_TIME,
)} minutes ago`,
throw new LogError(
`Email already sent less than ${printTimeDuration(CONFIG.EMAIL_CODE_REQUEST_TIME)} ago`,
)
}
@ -409,8 +388,7 @@ export class UserResolver {
user.emailContact.emailVerificationCode = random(64)
user.emailContact.emailOptInTypeId = OptInType.EMAIL_OPT_IN_RESET_PASSWORD
await user.emailContact.save().catch(() => {
logger.error('Unable to save email verification code= ' + user.emailContact)
throw new Error('Unable to save email verification code.')
throw new LogError('Unable to save email verification code', user.emailContact)
})
logger.info(`optInCode for ${email}=${user.emailContact}`)
@ -445,34 +423,23 @@ export class UserResolver {
logger.info(`setPassword(${code}, ***)...`)
// Validate Password
if (!isValidPassword(password)) {
logger.error('Password entered is lexically invalid')
throw new Error(
throw new LogError(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
}
// Load code
/*
const optInCode = await LoginEmailOptIn.findOneOrFail({ verificationCode: code }).catch(() => {
logger.error('Could not login with emailVerificationCode')
throw new Error('Could not login with emailVerificationCode')
})
*/
// load code
const userContact = await DbUserContact.findOneOrFail(
{ emailVerificationCode: code },
{ relations: ['user'] },
).catch(() => {
logger.error('Could not login with emailVerificationCode')
throw new Error('Could not login with emailVerificationCode')
throw new LogError('Could not login with emailVerificationCode')
})
logger.debug('userContact loaded...')
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
throw new LogError(
`Email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
}
logger.debug('EmailVerificationCode is valid...')
@ -498,13 +465,11 @@ export class UserResolver {
try {
// Save user
await queryRunner.manager.save(user).catch((error) => {
logger.error('error saving user: ' + error)
throw new Error('error saving user: ' + error)
throw new LogError('Error saving user', error)
})
// Save userContact
await queryRunner.manager.save(userContact).catch((error) => {
logger.error('error saving userContact: ' + error)
throw new Error('error saving userContact: ' + error)
throw new LogError('Error saving userContact', error)
})
await queryRunner.commitTransaction()
@ -515,8 +480,7 @@ export class UserResolver {
eventProtocol.writeEvent(event.setEventActivateAccount(eventActivateAccount))
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error('Error on writing User and UserContact data:' + e)
throw e
throw new LogError('Error on writing User and User Contact data', e)
} finally {
await queryRunner.release()
}
@ -530,7 +494,7 @@ export class UserResolver {
`klicktippSignIn(${userContact.email}, ${user.language}, ${user.firstName}, ${user.lastName})`,
)
} catch (e) {
logger.error('Error subscribe to klicktipp:' + e)
logger.error('Error subscribing to klicktipp', e)
// TODO is this a problem?
// eslint-disable-next-line no-console
/* uncomment this, when you need the activation link on the console
@ -550,11 +514,8 @@ export class UserResolver {
logger.debug(`found optInCode=${userContact}`)
// Code is only valid for `CONFIG.EMAIL_CODE_VALID_TIME` minutes
if (!isEmailVerificationCodeValid(userContact.updatedAt || userContact.createdAt)) {
logger.error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
throw new Error(
`email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
throw new LogError(
`Email was sent more than ${printTimeDuration(CONFIG.EMAIL_CODE_VALID_TIME)} ago`,
)
}
logger.info(`queryOptIn(${optIn}) successful...`)
@ -589,8 +550,7 @@ export class UserResolver {
if (language) {
if (!isLanguage(language)) {
logger.error(`"${language}" isn't a valid language`)
throw new Error(`"${language}" isn't a valid language`)
throw new LogError('Given language is not a valid language', language)
}
userEntity.language = language
i18n.setLocale(language)
@ -599,15 +559,13 @@ export class UserResolver {
if (password && passwordNew) {
// Validate Password
if (!isValidPassword(passwordNew)) {
logger.error('newPassword does not fullfil the rules')
throw new Error(
throw new LogError(
'Please enter a valid password with at least 8 characters, upper and lower case letters, at least one number and one special character!',
)
}
if (!verifyPassword(userEntity, password)) {
logger.error(`Old password is invalid`)
throw new Error(`Old password is invalid`)
throw new LogError(`Old password is invalid`)
}
// Save new password hash and newly encrypted private key
@ -630,16 +588,14 @@ export class UserResolver {
try {
await queryRunner.manager.save(userEntity).catch((error) => {
logger.error('error saving user: ' + error)
throw new Error('error saving user: ' + error)
throw new LogError('Error saving user', error)
})
await queryRunner.commitTransaction()
logger.debug('writing User data successful...')
} catch (e) {
await queryRunner.rollbackTransaction()
logger.error(`error on writing updated user data: ${e}`)
throw e
throw new LogError('Error on writing updated user data', e)
} finally {
await queryRunner.release()
}
@ -766,14 +722,12 @@ export class UserResolver {
const user = await DbUser.findOne({ id: userId })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
throw new LogError('Could not find user with given ID', userId)
}
// administrator user changes own role?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
logger.error('Administrator can not change his own role!')
throw new Error('Administrator can not change his own role!')
throw new LogError('Administrator can not change his own role')
}
// change isAdmin
switch (user.isAdmin) {
@ -781,16 +735,14 @@ export class UserResolver {
if (isAdmin === true) {
user.isAdmin = new Date()
} else {
logger.error('User is already a usual user!')
throw new Error('User is already a usual user!')
throw new LogError('User is already an usual user')
}
break
default:
if (isAdmin === false) {
user.isAdmin = null
} else {
logger.error('User is already admin!')
throw new Error('User is already admin!')
throw new LogError('User is already admin')
}
break
}
@ -808,14 +760,12 @@ export class UserResolver {
const user = await DbUser.findOne({ id: userId })
// user exists ?
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
throw new LogError('Could not find user with given ID', userId)
}
// moderator user disabled own account?
const moderatorUser = getUser(context)
if (moderatorUser.id === userId) {
logger.error('Moderator can not delete his own account!')
throw new Error('Moderator can not delete his own account!')
throw new LogError('Moderator can not delete his own account')
}
// soft-delete user
await user.softRemove()
@ -828,12 +778,10 @@ export class UserResolver {
async unDeleteUser(@Arg('userId', () => Int) userId: number): Promise<Date | null> {
const user = await DbUser.findOne({ id: userId }, { withDeleted: true })
if (!user) {
logger.error(`Could not find user with userId: ${userId}`)
throw new Error(`Could not find user with userId: ${userId}`)
throw new LogError('Could not find user with given ID', userId)
}
if (!user.deletedAt) {
logger.error('User is not deleted')
throw new Error('User is not deleted')
throw new LogError('User is not deleted')
}
await user.recover()
return null
@ -846,17 +794,14 @@ export class UserResolver {
// const user = await dbUser.findOne({ id: emailContact.userId })
const user = await findUserByEmail(email)
if (!user) {
logger.error(`Could not find User to emailContact: ${email}`)
throw new Error(`Could not find User to emailContact: ${email}`)
throw new LogError('Could not find user to given email contact', email)
}
if (user.deletedAt) {
logger.error(`User with emailContact: ${email} is deleted.`)
throw new Error(`User with emailContact: ${email} is deleted.`)
throw new LogError('User with given email contact is deleted', email)
}
const emailContact = user.emailContact
if (emailContact.deletedAt) {
logger.error(`The emailContact: ${email} of this User is deleted.`)
throw new Error(`The emailContact: ${email} of this User is deleted.`)
throw new LogError('The given email contact for this user is deleted', email)
}
emailContact.emailResendCount++
@ -893,8 +838,7 @@ export async function findUserByEmail(email: string): Promise<DbUser> {
{ email: email },
{ withDeleted: true, relations: ['user'] },
).catch(() => {
logger.error(`UserContact with email=${email} does not exists`)
throw new Error('No user with this credentials')
throw new LogError('No user with this credentials', email)
})
const dbUser = dbUserContact.user
dbUser.emailContact = dbUserContact
@ -909,31 +853,16 @@ async function checkEmailExists(email: string): Promise<boolean> {
return false
}
/*
const isTimeExpired = (optIn: LoginEmailOptIn, duration: number): boolean => {
const timeElapsed = Date.now() - new Date(optIn.updatedAt).getTime()
// time is given in minutes
return timeElapsed <= duration * 60 * 1000
}
*/
const isTimeExpired = (updatedAt: Date, duration: number): boolean => {
const timeElapsed = Date.now() - new Date(updatedAt).getTime()
// time is given in minutes
return timeElapsed <= duration * 60 * 1000
}
/*
const isOptInValid = (optIn: LoginEmailOptIn): boolean => {
return isTimeExpired(optIn, CONFIG.EMAIL_CODE_VALID_TIME)
}
*/
const isEmailVerificationCodeValid = (updatedAt: Date): boolean => {
return isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_VALID_TIME)
}
/*
const canResendOptIn = (optIn: LoginEmailOptIn): boolean => {
return !isTimeExpired(optIn, CONFIG.EMAIL_CODE_REQUEST_TIME)
}
*/
const canEmailResend = (updatedAt: Date): boolean => {
return !isTimeExpired(updatedAt, CONFIG.EMAIL_CODE_REQUEST_TIME)
}

View File

@ -172,18 +172,23 @@ export const listContributions = gql`
`
export const listAllContributions = `
query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC) {
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order) {
query ($currentPage: Int = 1, $pageSize: Int = 5, $order: Order = DESC, $statusFilter: [ContributionStatus!]) {
listAllContributions(currentPage: $currentPage, pageSize: $pageSize, order: $order, statusFilter: $statusFilter) {
contributionCount
contributionList {
id
firstName
lastName
amount
memo
createdAt
confirmedAt
confirmedBy
firstName
lastName
amount
memo
createdAt
confirmedAt
confirmedBy
contributionDate
state
messagesCount
deniedAt
deniedBy
}
}
}

View File

@ -0,0 +1,26 @@
import { logger } from '@test/testSetup'
import LogError from './LogError'
describe('LogError', () => {
it('logs an Error when created', () => {
/* eslint-disable-next-line no-new */
new LogError('new LogError')
expect(logger.error).toBeCalledWith('new LogError')
})
it('logs an Error including additional data when created', () => {
/* eslint-disable-next-line no-new */
new LogError('new LogError', { some: 'data' })
expect(logger.error).toBeCalledWith('new LogError', { some: 'data' })
})
it('does not contain additional data in Error object when thrown', () => {
try {
throw new LogError('new LogError', { someWeirdValue123: 'arbitraryData456' })
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
} catch (e: any) {
expect(e.stack).not.toMatch(/(someWeirdValue123|arbitraryData456)/i)
}
})
})

View File

@ -0,0 +1,9 @@
import { backendLogger as logger } from './logger'
export default class LogError extends Error {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
constructor(msg: string, ...details: any[]) {
super(msg)
logger.error(msg, ...details)
}
}

22
dht-node/.env.dist Normal file
View File

@ -0,0 +1,22 @@
CONFIG_VERSION=v1.2023-01-01
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_DATABASE=gradido_community
TYPEORM_LOGGING_RELATIVE_PATH=typeorm.dht-node.log
# EventProtocol
EVENT_PROTOCOL_DISABLED=false
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
# LOG_LEVEL=info
# 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

17
dht-node/.env.template Normal file
View File

@ -0,0 +1,17 @@
CONFIG_VERSION=$BACKEND_CONFIG_VERSION
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=$DB_USER
DB_PASSWORD=$DB_PASSWORD
DB_DATABASE=gradido_community
TYPEORM_LOGGING_RELATIVE_PATH=$TYPEORM_LOGGING_RELATIVE_PATH
# EventProtocol
EVENT_PROTOCOL_DISABLED=$EVENT_PROTOCOL_DISABLED
# Federation
FEDERATION_DHT_TOPIC=$FEDERATION_DHT_TOPIC
FEDERATION_DHT_SEED=$FEDERATION_DHT_SEED
FEDERATION_COMMUNITY_URL=$FEDERATION_COMMUNITY_URL

3
dht-node/.eslintignore Normal file
View File

@ -0,0 +1,3 @@
node_modules
**/*.min.js
build

26
dht-node/.eslintrc.js Normal file
View File

@ -0,0 +1,26 @@
module.exports = {
root: true,
env: {
node: true,
// jest: true,
},
parser: '@typescript-eslint/parser',
plugins: ['prettier', '@typescript-eslint' /*, 'jest' */],
extends: [
'standard',
'eslint:recommended',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
],
// add your custom rules here
rules: {
'no-console': ['error'],
'no-debugger': 'error',
'prettier/prettier': [
'error',
{
htmlWhitespaceSensitivity: 'ignore',
},
],
},
}

8
dht-node/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/node_modules/
/.env
/.env.bak
/build/
package-json.lock
coverage
# emacs
*~

9
dht-node/.prettierrc.js Normal file
View File

@ -0,0 +1,9 @@
module.exports = {
semi: false,
printWidth: 100,
singleQuote: true,
trailingComma: "all",
tabWidth: 2,
bracketSpacing: true,
endOfLine: "auto",
};

119
dht-node/Dockerfile Normal file
View File

@ -0,0 +1,119 @@
##################################################################################
# BASE ###########################################################################
##################################################################################
FROM node:19.5.0-alpine3.17 as base
#FROM ubuntu:latest as base
# ENVs (available in production aswell, can be overwritten by commandline or env file)
## DOCKER_WORKDIR would be a classical ARG, but that is not multi layer persistent - shame
ENV DOCKER_WORKDIR="/app"
## We Cannot do `$(date -u +'%Y-%m-%dT%H:%M:%SZ')` here so we use unix timestamp=0
ENV BUILD_DATE="1970-01-01T00:00:00.00Z"
## We cannot do $(npm run version).${BUILD_NUMBER} here so we default to 0.0.0.0
ENV BUILD_VERSION="0.0.0.0"
## We cannot do `$(git rev-parse --short HEAD)` here so we default to 0000000
ENV BUILD_COMMIT="0000000"
## SET NODE_ENV
ENV NODE_ENV="production"
## App relevant Envs
#ENV PORT="5000"
# Labels
LABEL org.label-schema.build-date="${BUILD_DATE}"
LABEL org.label-schema.name="gradido:dht-node"
LABEL org.label-schema.description="Gradido dht-node"
LABEL org.label-schema.usage="https://github.com/gradido/gradido/blob/master/README.md"
LABEL org.label-schema.url="https://gradido.net"
LABEL org.label-schema.vcs-url="https://github.com/gradido/gradido/tree/master/dht-node"
LABEL org.label-schema.vcs-ref="${BUILD_COMMIT}"
LABEL org.label-schema.vendor="Gradido Community"
LABEL org.label-schema.version="${BUILD_VERSION}"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="support@gradido.net"
# Install Additional Software
## install: sodium requirements
RUN apk add --no-cache --virtual build-deps python3 alpine-sdk autoconf libtool automake && \
mkdir -p /prebuilds && cd /prebuilds && npm init -y && npm install sodium-native@3.1.1 && \
apk del build-deps
ENV SODIUM_NATIVE_PREBUILD=/prebuilds/node_modules/sodium-native/
# Settings
## Expose Container Port
#EXPOSE ${PORT}
## Workdir
RUN mkdir -p ${DOCKER_WORKDIR}
WORKDIR ${DOCKER_WORKDIR}
RUN mkdir -p /database
##################################################################################
# DEVELOPMENT (Connected to the local environment, to reload on demand) ##########
##################################################################################
FROM base as development
# We don't need to copy or build anything since we gonna bind to the
# local filesystem which will need a rebuild anyway
# Run command
# (for development we need to execute yarn install since the
# node_modules are on another volume and need updating)
CMD /bin/sh -c "cd /database && yarn install && yarn build && cd /app && yarn install && yarn run dev"
##################################################################################
# BUILD (Does contain all files and is therefore bloated) ########################
##################################################################################
FROM base as build
# Copy everything from dht-node
COPY ./dht-node/ ./
# Copy everything from database
COPY ./database/ ../database/
# yarn install dht-node
RUN yarn install --production=false --frozen-lockfile --non-interactive
# yarn install database
RUN cd ../database && yarn install --production=false --frozen-lockfile --non-interactive
# yarn build
RUN yarn run build
# yarn build database
RUN cd ../database && yarn run build
##################################################################################
# TEST ###########################################################################
##################################################################################
FROM build as test
# Run command
CMD /bin/sh -c "yarn run start"
##################################################################################
# PRODUCTION (Does contain only "binary"- and static-files to reduce image size) #
##################################################################################
FROM base as production
# Copy "binary"-files from build image
COPY --from=build ${DOCKER_WORKDIR}/build ./build
COPY --from=build ${DOCKER_WORKDIR}/../database/build ../database/build
# We also copy the node_modules express and serve-static for the run script
COPY --from=build ${DOCKER_WORKDIR}/node_modules ./node_modules
COPY --from=build ${DOCKER_WORKDIR}/../database/node_modules ../database/node_modules
# Copy static files
# COPY --from=build ${DOCKER_WORKDIR}/public ./public
# Copy package.json for script definitions (lock file should not be needed)
COPY --from=build ${DOCKER_WORKDIR}/package.json ./package.json
# Copy tsconfig.json to provide alias path definitions
COPY --from=build ${DOCKER_WORKDIR}/tsconfig.json ./tsconfig.json
# Copy log4js-config.json to provide log configuration
COPY --from=build ${DOCKER_WORKDIR}/log4js-config.json ./log4js-config.json
# Copy run scripts run/
# COPY --from=build ${DOCKER_WORKDIR}/run ./run
# Run command
CMD /bin/sh -c "yarn run start"

22
dht-node/jest.config.js Normal file
View File

@ -0,0 +1,22 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
verbose: true,
preset: 'ts-jest',
collectCoverage: true,
collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'],
setupFiles: ['<rootDir>/test/testSetup.ts'],
setupFilesAfterEnv: [],
modulePathIgnorePatterns: ['<rootDir>/build/'],
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
'@test/(.*)': '<rootDir>/test/$1',
'@entity/(.*)':
process.env.NODE_ENV === 'development'
? '<rootDir>/../database/entity/$1'
: '<rootDir>/../database/build/entity/$1',
'@dbTools/(.*)':
process.env.NODE_ENV === 'development'
? '<rootDir>/../database/src/$1'
: '<rootDir>/../database/build/src/$1',
},
}

View File

@ -0,0 +1,69 @@
{
"appenders":
{
"dht":
{
"type": "dateFile",
"filename": "../logs/dht-node/apiversion-%v.log",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_",
"numBackups" : 30
},
"errorFile":
{
"type": "dateFile",
"filename": "../logs/dht-node/errors.log",
"pattern": "yyyy-MM-dd",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %c [%X{user}] [%f : %l] - %m"
},
"keepFileExt" : true,
"fileNameSep" : "_",
"numBackups" : 30
},
"errors":
{
"type": "logLevelFilter",
"level": "error",
"appender": "errorFile"
},
"out":
{
"type": "stdout",
"layout":
{
"type": "pattern", "pattern": "%d{ISO8601} %p %c [%X{user}] [%f : %l] - %m"
}
}
},
"categories":
{
"default":
{
"appenders":
[
"out",
"errors"
],
"level": "debug",
"enableCallStack": true
},
"dht":
{
"appenders":
[
"dht",
"out",
"errors"
],
"level": "debug",
"enableCallStack": true
}
}
}

45
dht-node/package.json Normal file
View File

@ -0,0 +1,45 @@
{
"name": "gradido-dht-node",
"version": "1.0.0",
"description": "Gradido dht-node module",
"main": "src/index.ts",
"repository": "https://github.com/gradido/gradido/",
"author": "Claus-Peter Huebner",
"license": "Apache-2.0",
"private": false,
"scripts": {
"build": "tsc --build",
"clean": "tsc --build --clean",
"start": "cross-env TZ=UTC TS_NODE_BASEURL=./build node -r tsconfig-paths/register build/src/index.js",
"dev": "cross-env TZ=UTC nodemon -w src --ext ts --exec ts-node -r dotenv/config -r tsconfig-paths/register src/index.ts",
"lint": "eslint --max-warnings=0 --ext .js,.ts .",
"test": "cross-env TZ=UTC NODE_ENV=development jest --runInBand --coverage --forceExit --detectOpenHandles"
},
"dependencies": {
"@hyperswarm/dht": "^6.4.4",
"cross-env": "^7.0.3",
"dotenv": "10.0.0",
"log4js": "^6.7.1",
"nodemon": "^2.0.20",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.2",
"typescript": "^4.9.4"
},
"devDependencies": {
"@types/dotenv": "^8.2.0",
"@types/jest": "^27.0.2",
"@types/node": "^18.11.18",
"@typescript-eslint/eslint-plugin": "^5.48.0",
"@typescript-eslint/parser": "^5.48.0",
"eslint": "^8.31.0",
"eslint-config-prettier": "^8.3.0",
"eslint-config-standard": "^17.0.0",
"eslint-plugin-import": "^2.23.4",
"eslint-plugin-n": "^15.6.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-promise": "^6.1.1",
"jest": "^27.2.4",
"prettier": "^2.3.1",
"ts-jest": "^27.0.5"
}
}

View File

@ -0,0 +1,62 @@
// ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env)
import dotenv from 'dotenv'
dotenv.config()
const constants = {
DB_VERSION: '0059-add_hide_amount_to_users',
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: 'v1.2023-01-01',
CURRENT: '',
},
}
const server = {
PRODUCTION: process.env.NODE_ENV === 'production' || false,
}
const database = {
DB_HOST: process.env.DB_HOST || 'localhost',
DB_PORT: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306,
DB_USER: process.env.DB_USER || 'root',
DB_PASSWORD: process.env.DB_PASSWORD || '',
DB_DATABASE: process.env.DB_DATABASE || 'gradido_community',
TYPEORM_LOGGING_RELATIVE_PATH:
process.env.TYPEORM_LOGGING_RELATIVE_PATH || 'typeorm.dht-node.log',
}
const eventProtocol = {
// global switch to enable writing of EventProtocol-Entries
EVENT_PROTOCOL_DISABLED: process.env.EVENT_PROTOCOL_DISABLED === 'true' || false,
}
const federation = {
FEDERATION_DHT_TOPIC: process.env.FEDERATION_DHT_TOPIC || 'GRADIDO_HUB',
FEDERATION_DHT_SEED: process.env.FEDERATION_DHT_SEED || null,
FEDERATION_COMMUNITY_URL: process.env.FEDERATION_COMMUNITY_URL || null,
}
// Check config version
constants.CONFIG_VERSION.CURRENT = process.env.CONFIG_VERSION || constants.CONFIG_VERSION.DEFAULT
if (
![constants.CONFIG_VERSION.EXPECTED, constants.CONFIG_VERSION.DEFAULT].includes(
constants.CONFIG_VERSION.CURRENT,
)
) {
throw new Error(
`Fatal: Config Version incorrect - expected "${constants.CONFIG_VERSION.EXPECTED}" or "${constants.CONFIG_VERSION.DEFAULT}", but found "${constants.CONFIG_VERSION.CURRENT}"`,
)
}
const CONFIG = {
...constants,
...server,
...database,
...eventProtocol,
...federation,
}
export default CONFIG

View File

@ -0,0 +1 @@
declare module '@hyperswarm/dht'

View File

@ -0,0 +1,798 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { startDHT } from './index'
import DHT from '@hyperswarm/dht'
import CONFIG from '@/config'
import { logger } from '@test/testSetup'
import { Community as DbCommunity } from '@entity/Community'
import { testEnvironment, cleanDB } from '@test/helpers'
CONFIG.FEDERATION_DHT_SEED = '64ebcb0e3ad547848fef4197c6e2332f'
jest.mock('@hyperswarm/dht')
const TEST_TOPIC = 'gradido_test_topic'
const keyPairMock = {
publicKey: Buffer.from('publicKey'),
secretKey: Buffer.from('secretKey'),
}
const serverListenSpy = jest.fn()
const serverEventMocks: { [key: string]: any } = {}
const serverOnMock = jest.fn().mockImplementation((key: string, callback) => {
serverEventMocks[key] = callback
})
const nodeCreateServerMock = jest.fn().mockImplementation(() => {
return {
on: serverOnMock,
listen: serverListenSpy,
}
})
const nodeAnnounceMock = jest.fn().mockImplementation(() => {
return {
finished: jest.fn(),
}
})
const lookupResultMock = {
token: Buffer.from(TEST_TOPIC),
from: {
id: Buffer.from('somone'),
host: '188.95.53.5',
port: 63561,
},
to: { id: null, host: '83.53.31.27', port: 55723 },
peers: [
{
publicKey: Buffer.from('some-public-key'),
relayAddresses: [],
},
],
}
const nodeLookupMock = jest.fn().mockResolvedValue([lookupResultMock])
const socketEventMocks: { [key: string]: any } = {}
const socketOnMock = jest.fn().mockImplementation((key: string, callback) => {
socketEventMocks[key] = callback
})
const socketWriteMock = jest.fn()
const nodeConnectMock = jest.fn().mockImplementation(() => {
return {
on: socketOnMock,
once: socketOnMock,
write: socketWriteMock,
}
})
DHT.hash.mockImplementation(() => {
return Buffer.from(TEST_TOPIC)
})
DHT.keyPair.mockImplementation(() => {
return keyPairMock
})
DHT.mockImplementation(() => {
return {
createServer: nodeCreateServerMock,
announce: nodeAnnounceMock,
lookup: nodeLookupMock,
connect: nodeConnectMock,
}
})
let con: any
let testEnv: any
beforeAll(async () => {
testEnv = await testEnvironment()
con = testEnv.con
await cleanDB()
})
afterAll(async () => {
await cleanDB()
await con.close()
})
describe('federation', () => {
beforeAll(() => {
jest.useFakeTimers()
})
describe('call startDHT', () => {
const hashSpy = jest.spyOn(DHT, 'hash')
const keyPairSpy = jest.spyOn(DHT, 'keyPair')
beforeEach(async () => {
DHT.mockClear()
jest.clearAllMocks()
await startDHT(TEST_TOPIC)
})
it('calls DHT.hash', () => {
expect(hashSpy).toBeCalledWith(Buffer.from(TEST_TOPIC))
})
it('creates a key pair', () => {
expect(keyPairSpy).toBeCalledWith(expect.any(Buffer))
})
it('initializes a new DHT object', () => {
expect(DHT).toBeCalledWith({ keyPair: keyPairMock })
})
describe('DHT node', () => {
it('creates a server', () => {
expect(nodeCreateServerMock).toBeCalled()
})
it('listens on the server', () => {
expect(serverListenSpy).toBeCalled()
})
describe('timers', () => {
beforeEach(() => {
jest.runOnlyPendingTimers()
})
it('announces on topic', () => {
expect(nodeAnnounceMock).toBeCalledWith(Buffer.from(TEST_TOPIC), keyPairMock)
})
it('looks up on topic', () => {
expect(nodeLookupMock).toBeCalledWith(Buffer.from(TEST_TOPIC))
})
})
describe('server connection event', () => {
beforeEach(() => {
serverEventMocks.connection({
remotePublicKey: Buffer.from('another-public-key'),
on: socketOnMock,
})
})
it('can be triggered', () => {
expect(socketOnMock).toBeCalled()
})
describe('socket events', () => {
describe('on data', () => {
describe('with receiving simply a string', () => {
beforeEach(() => {
jest.clearAllMocks()
socketEventMocks.data(Buffer.from('no-json string'))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith('data: no-json string')
})
it('logs an error of unexpected data format and structure', () => {
expect(logger.error).toBeCalledWith(
'Error on receiving data from socket:',
new SyntaxError('Unexpected token \'o\', "no-json string" is not valid JSON'),
)
})
})
describe('with receiving array of strings', () => {
beforeEach(() => {
jest.clearAllMocks()
const strArray: string[] = ['invalid type test', 'api', 'url']
socketEventMocks.data(Buffer.from(strArray.toString()))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith('data: invalid type test,api,url')
})
it('logs an error of unexpected data format and structure', () => {
expect(logger.error).toBeCalledWith(
'Error on receiving data from socket:',
new SyntaxError('Unexpected token \'i\', "invalid ty"... is not valid JSON'),
)
})
})
describe('with receiving array of string-arrays', () => {
beforeEach(async () => {
jest.clearAllMocks()
const strArray: string[][] = [
[`api`, `url`, `invalid type in array test`],
[`wrong`, `api`, `url`],
]
await socketEventMocks.data(Buffer.from(strArray.toString()))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(
'data: api,url,invalid type in array test,wrong,api,url',
)
})
it('logs an error of unexpected data format and structure', () => {
expect(logger.error).toBeCalledWith(
'Error on receiving data from socket:',
new SyntaxError('Unexpected token \'a\', "api,url,in"... is not valid JSON'),
)
})
})
describe('with receiving JSON-Array with too much entries', () => {
let jsonArray: { api: string; url: string }[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 'v1_0', url: 'too much versions at the same time test' },
{ api: 'v1_0', url: 'url2' },
{ api: 'v1_0', url: 'url3' },
{ api: 'v1_0', url: 'url4' },
{ api: 'v1_0', url: 'url5' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(
'data: [{"api":"v1_0","url":"too much versions at the same time test"},{"api":"v1_0","url":"url2"},{"api":"v1_0","url":"url3"},{"api":"v1_0","url":"url4"},{"api":"v1_0","url":"url5"}]',
)
})
it('logs a warning of too much apiVersion-Definitions', () => {
expect(logger.warn).toBeCalledWith(
`received totaly wrong or too much apiVersions-Definition JSON-String: ${JSON.stringify(
jsonArray,
)}`,
)
})
})
describe('with receiving wrong but tolerated property data', () => {
let jsonArray: any[]
let result: DbCommunity[] = []
beforeAll(async () => {
jest.clearAllMocks()
jsonArray = [
{
wrong: 'wrong but tolerated property test',
api: 'v1_0',
url: 'url1',
},
{
api: 'v2_0',
url: 'url2',
wrong: 'wrong but tolerated property test',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
result = await DbCommunity.find()
})
afterAll(async () => {
await cleanDB()
})
it('has two Communty entries in database', () => {
expect(result).toHaveLength(2)
})
it('has an entry for api version v1_0', () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'v1_0',
endPoint: 'url1',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it('has an entry for api version v2_0', () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'v2_0',
endPoint: 'url2',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
})
describe('with receiving data but missing api property', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ test1: 'missing api proterty test', url: 'any url definition as string' },
{ api: 'some api', test2: 'missing url property test' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but missing url property', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 'some api', test2: 'missing url property test' },
{ test1: 'missing api proterty test', url: 'any url definition as string' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but wrong type of api property', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 1, url: 'wrong property type tests' },
{ api: 'urltyptest', url: 2 },
{ api: 1, url: 2 },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but wrong type of url property', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 'urltyptest', url: 2 },
{ api: 1, url: 'wrong property type tests' },
{ api: 1, url: 2 },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but wrong type of both properties', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 1, url: 2 },
{ api: 'urltyptest', url: 2 },
{ api: 1, url: 'wrong property type tests' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received invalid apiVersion-Definition: ${JSON.stringify(jsonArray[0])}`,
)
})
})
describe('with receiving data but too long api string', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{ api: 'toolong api', url: 'some valid url' },
{
api: 'valid api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{
api: 'toolong api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received apiVersion with content longer than max length: ${JSON.stringify(
jsonArray[0],
)}`,
)
})
})
describe('with receiving data but too long url string', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{ api: 'toolong api', url: 'some valid url' },
{
api: 'toolong api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
it('logs a warning of invalid apiVersion-Definition', () => {
expect(logger.warn).toBeCalledWith(
`received apiVersion with content longer than max length: ${JSON.stringify(
jsonArray[0],
)}`,
)
})
})
describe('with receiving data but both properties with too long strings', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'toolong api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{
api: 'api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{ api: 'toolong api', url: 'some valid url' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.info).toBeCalledWith(`data: ${JSON.stringify(jsonArray)}`)
})
})
describe('with receiving data of exact max allowed properties length', () => {
let jsonArray: any[]
let result: DbCommunity[] = []
beforeAll(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'valid api',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'api',
url: 'this is a too long url definition with exact one character more than the allowed two hundert and fiftyfive characters. and here begins the fill characters with no sense of content menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmic',
},
{ api: 'toolong api', url: 'some valid url' },
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
result = await DbCommunity.find()
})
afterAll(async () => {
await cleanDB()
})
it('has one Communty entry in database', () => {
expect(result).toHaveLength(1)
})
it(`has an entry with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
})
describe('with receiving data of exact max allowed buffer length', () => {
let jsonArray: any[]
let result: DbCommunity[] = []
beforeAll(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'valid api1',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api2',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api3',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api4',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
result = await DbCommunity.find()
})
afterAll(async () => {
await cleanDB()
})
it('has five Communty entries in database', () => {
expect(result).toHaveLength(4)
})
it(`has an entry 'valid api1' with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api1',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it(`has an entry 'valid api2' with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api2',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it(`has an entry 'valid api3' with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api3',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it(`has an entry 'valid api4' with max content length for api and url`, () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'valid api4',
endPoint:
'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
})
describe('with receiving data longer than max allowed buffer length', () => {
let jsonArray: any[]
beforeEach(async () => {
jest.clearAllMocks()
jsonArray = [
{
api: 'Xvalid api1',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api2',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api3',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
{
api: 'valid api4',
url: 'this is a valid url definition with exact the max allowed length of two hundert and fiftyfive characters. and here begins the fill characters with no sense of content kuhwarmiga menschhabicheinhungerdassichnichtweiswoichheutnachtschlafensollsofriertesmich',
},
]
await socketEventMocks.data(Buffer.from(JSON.stringify(jsonArray)))
})
it('logs the received data', () => {
expect(logger.warn).toBeCalledWith(
`received more than max allowed length of data buffer: ${
JSON.stringify(jsonArray).length
} against 1141 max allowed`,
)
})
})
describe('with proper data', () => {
let result: DbCommunity[] = []
beforeAll(async () => {
jest.clearAllMocks()
await socketEventMocks.data(
Buffer.from(
JSON.stringify([
{
api: 'v1_0',
url: 'http://localhost:4000/api/v1_0',
},
{
api: 'v2_0',
url: 'http://localhost:4000/api/v2_0',
},
]),
),
)
result = await DbCommunity.find()
})
afterAll(async () => {
await cleanDB()
})
it('has two Communty entries in database', () => {
expect(result).toHaveLength(2)
})
it('has an entry for api version v1_0', () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'v1_0',
endPoint: 'http://localhost:4000/api/v1_0',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
it('has an entry for api version v2_0', () => {
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: expect.any(Number),
publicKey: expect.any(Buffer),
apiVersion: 'v2_0',
endPoint: 'http://localhost:4000/api/v2_0',
lastAnnouncedAt: expect.any(Date),
createdAt: expect.any(Date),
updatedAt: null,
}),
]),
)
})
})
})
describe('on open', () => {
beforeEach(() => {
socketEventMocks.open()
})
it.skip('calls socket write with own api versions', () => {
expect(socketWriteMock).toBeCalledWith(
Buffer.from(
JSON.stringify([
{
api: 'v1_0',
url: 'http://localhost:4000/api/v1_0',
},
{
api: 'v1_1',
url: 'http://localhost:4000/api/v1_1',
},
{
api: 'v2_0',
url: 'http://localhost:4000/api/v2_0',
},
]),
),
)
})
})
})
})
})
})
})

View File

@ -0,0 +1,186 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import DHT from '@hyperswarm/dht'
import { logger } from '@/server/logger'
import CONFIG from '@/config'
import { Community as DbCommunity } from '@entity/Community'
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
const ANNOUNCETIME = 30000
enum ApiVersionType {
V1_0 = 'v1_0',
V1_1 = 'v1_1',
V2_0 = 'v2_0',
}
type CommunityApi = {
api: string
url: string
}
export const startDHT = async (topic: string): Promise<void> => {
try {
const TOPIC = DHT.hash(Buffer.from(topic))
const keyPair = DHT.keyPair(getSeed())
logger.info(`keyPairDHT: publicKey=${keyPair.publicKey.toString('hex')}`)
logger.debug(`keyPairDHT: secretKey=${keyPair.secretKey.toString('hex')}`)
const ownApiVersions = Object.values(ApiVersionType).map(function (apiEnum) {
const comApi: CommunityApi = {
api: apiEnum,
url: CONFIG.FEDERATION_COMMUNITY_URL + apiEnum,
}
return comApi
})
logger.debug(`ApiList: ${JSON.stringify(ownApiVersions)}`)
const node = new DHT({ keyPair })
const server = node.createServer()
server.on('connection', function (socket: any) {
logger.info(`server on... with Remote public key: ${socket.remotePublicKey.toString('hex')}`)
socket.on('data', async (data: Buffer) => {
try {
if (data.length > 1141) {
logger.warn(
`received more than max allowed length of data buffer: ${data.length} against 1141 max allowed`,
)
return
}
logger.info(`data: ${data.toString('ascii')}`)
const recApiVersions: CommunityApi[] = JSON.parse(data.toString('ascii'))
// TODO better to introduce the validation by https://github.com/typestack/class-validator
if (!recApiVersions || !Array.isArray(recApiVersions) || recApiVersions.length >= 5) {
logger.warn(
`received totaly wrong or too much apiVersions-Definition JSON-String: ${JSON.stringify(
recApiVersions,
)}`,
)
return
}
for (const recApiVersion of recApiVersions) {
if (
!recApiVersion.api ||
typeof recApiVersion.api !== 'string' ||
!recApiVersion.url ||
typeof recApiVersion.url !== 'string'
) {
logger.warn(
`received invalid apiVersion-Definition: ${JSON.stringify(recApiVersion)}`,
)
return
}
// TODO better to introduce the validation on entity-Level by https://github.com/typestack/class-validator
if (recApiVersion.api.length > 10 || recApiVersion.url.length > 255) {
logger.warn(
`received apiVersion with content longer than max length: ${JSON.stringify(
recApiVersion,
)}`,
)
return
}
const variables = {
apiVersion: recApiVersion.api,
endPoint: recApiVersion.url,
publicKey: socket.remotePublicKey.toString('hex'),
lastAnnouncedAt: new Date(),
}
logger.debug(`upsert with variables=${JSON.stringify(variables)}`)
// this will NOT update the updatedAt column, to distingue between a normal update and the last announcement
await DbCommunity.createQueryBuilder()
.insert()
.into(DbCommunity)
.values(variables)
.orUpdate({
conflict_target: ['id', 'publicKey', 'apiVersion'],
overwrite: ['end_point', 'last_announced_at'],
})
.execute()
logger.info(`federation community upserted successfully...`)
}
} catch (e) {
logger.error('Error on receiving data from socket:', e)
}
})
})
await server.listen()
setInterval(async () => {
logger.info(`Announcing on topic: ${TOPIC.toString('hex')}`)
await node.announce(TOPIC, keyPair).finished()
}, ANNOUNCETIME)
let successfulRequests: string[] = []
let errorfulRequests: string[] = []
setInterval(async () => {
logger.info('Refreshing successful nodes')
successfulRequests = []
}, SUCCESSTIME)
setInterval(async () => {
logger.info('Refreshing errorful nodes')
errorfulRequests = []
}, ERRORTIME)
setInterval(async () => {
const result = await node.lookup(TOPIC)
const collectedPubKeys: string[] = []
for await (const data of result) {
data.peers.forEach((peer: any) => {
const pubKey = peer.publicKey.toString('hex')
if (
pubKey !== keyPair.publicKey.toString('hex') &&
!successfulRequests.includes(pubKey) &&
!errorfulRequests.includes(pubKey) &&
!collectedPubKeys.includes(pubKey)
) {
collectedPubKeys.push(peer.publicKey.toString('hex'))
}
})
}
if (collectedPubKeys.length) {
logger.info(`Found new peers: ${collectedPubKeys}`)
}
collectedPubKeys.forEach((remotePubKey) => {
const socket = node.connect(Buffer.from(remotePubKey, 'hex'))
// socket.once("connect", function () {
// console.log("client side emitted connect");
// });
// socket.once("end", function () {
// console.log("client side ended");
// });
socket.once('error', (err: any) => {
errorfulRequests.push(remotePubKey)
logger.error(`error on peer ${remotePubKey}: ${err.message}`)
})
socket.on('open', function () {
socket.write(Buffer.from(JSON.stringify(ownApiVersions)))
successfulRequests.push(remotePubKey)
})
})
}, POLLTIME)
} catch (err) {
logger.error('DHT unexpected error:', err)
}
}

38
dht-node/src/index.ts Normal file
View File

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { startDHT } from '@/dht_node/index'
// config
import CONFIG from './config'
import { logger } from './server/logger'
import connection from './typeorm/connection'
import { checkDBVersion } from './typeorm/DBVersion'
async function main() {
// open mysql connection
const con = await connection()
if (!con || !con.isConnected) {
logger.fatal(`Couldn't open connection to database!`)
throw new Error(`Fatal: Couldn't open connection to database`)
}
// check for correct database version
const dbVersion = await checkDBVersion(CONFIG.DB_VERSION)
if (!dbVersion) {
logger.fatal('Fatal: Database Version incorrect')
throw new Error('Fatal: Database Version incorrect')
}
// 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)
}
main().catch((e) => {
// eslint-disable-next-line no-console
console.error(e)
process.exit(1)
})

View File

@ -0,0 +1,20 @@
import log4js from 'log4js'
import CONFIG from '@/config'
import { readFileSync } from 'fs'
const options = JSON.parse(readFileSync(CONFIG.LOG4JS_CONFIG, 'utf-8'))
options.categories.dht.level = CONFIG.LOG_LEVEL
let filename: string = options.appenders.dht.filename
options.appenders.dht.filename = filename.replace(
'apiversion-%v',
'dht-' + CONFIG.FEDERATION_DHT_TOPIC,
)
filename = options.appenders.errorFile.filename
log4js.configure(options)
const logger = log4js.getLogger('dht')
export { logger }

View File

@ -0,0 +1,27 @@
import { Migration } from '@entity/Migration'
import { logger } from '@/server/logger'
const getDBVersion = async (): Promise<string | null> => {
try {
const dbVersion = await Migration.findOne({ order: { version: 'DESC' } })
return dbVersion ? dbVersion.fileName : null
} catch (error) {
logger.error(error)
return null
}
}
const checkDBVersion = async (DB_VERSION: string): Promise<boolean> => {
const dbVersion = await getDBVersion()
if (!dbVersion || dbVersion.indexOf(DB_VERSION) === -1) {
logger.error(
`Wrong database version detected - the dht-node requires '${DB_VERSION}' but found '${
dbVersion || 'None'
}`,
)
return false
}
return true
}
export { checkDBVersion, getDBVersion }

View File

@ -0,0 +1,34 @@
// TODO This is super weird - since the entities are defined in another project they have their own globals.
// We cannot use our connection here, but must use the external typeorm installation
import { Connection, createConnection, FileLogger } from '@dbTools/typeorm'
import CONFIG from '@/config'
import { entities } from '@entity/index'
const connection = async (): Promise<Connection | null> => {
try {
return createConnection({
name: 'default',
type: 'mysql',
host: CONFIG.DB_HOST,
port: CONFIG.DB_PORT,
username: CONFIG.DB_USER,
password: CONFIG.DB_PASSWORD,
database: CONFIG.DB_DATABASE,
entities,
synchronize: false,
logging: true,
logger: new FileLogger('all', {
logPath: CONFIG.TYPEORM_LOGGING_RELATIVE_PATH,
}),
extra: {
charset: 'utf8mb4_unicode_ci',
},
})
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
return null
}
}
export default connection

View File

@ -0,0 +1,7 @@
import { contributionDateFormatter } from '@test/helpers'
describe('contributionDateFormatter', () => {
it('formats the date correctly', () => {
expect(contributionDateFormatter(new Date('Thu Feb 29 2024 13:12:11'))).toEqual('2/29/2024')
})
})

68
dht-node/test/helpers.ts Normal file
View File

@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import CONFIG from '@/config'
import connection from '@/typeorm/connection'
import { checkDBVersion } from '@/typeorm/DBVersion'
import { initialize } from '@dbTools/helpers'
import { entities } from '@entity/index'
import { logger } from './testSetup'
export const headerPushMock = jest.fn((t) => {
context.token = t.value
})
const context = {
token: '',
setHeaders: {
push: headerPushMock,
forEach: jest.fn(),
},
clientTimezoneOffset: 0,
}
export const cleanDB = async () => {
// this only works as long we do not have foreign key constraints
for (let i = 0; i < entities.length; i++) {
await resetEntity(entities[i])
}
}
export const testEnvironment = async () => {
// open mysql connection
const con = await connection()
if (!con || !con.isConnected) {
logger.fatal(`Couldn't open connection to database!`)
throw new Error(`Fatal: Couldn't open connection to database`)
}
// check for correct database version
const dbVersion = await checkDBVersion(CONFIG.DB_VERSION)
if (!dbVersion) {
logger.fatal('Fatal: Database Version incorrect')
throw new Error('Fatal: Database Version incorrect')
}
await initialize()
return { con }
}
export const resetEntity = async (entity: any) => {
const items = await entity.find({ withDeleted: true })
if (items.length > 0) {
const ids = items.map((i: any) => i.id)
await entity.delete(ids)
}
}
export const resetToken = () => {
context.token = ''
}
// format date string as it comes from the frontend for the contribution date
export const contributionDateFormatter = (date: Date): string => {
return `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
}
export const setClientTimezoneOffset = (offset: number): void => {
context.clientTimezoneOffset = offset
}

View File

@ -0,0 +1,22 @@
import { logger } from '@/server/logger'
jest.setTimeout(1000000)
jest.mock('@/server/logger', () => {
const originalModule = jest.requireActual('@/server/logger')
return {
__esModule: true,
...originalModule,
logger: {
addContext: jest.fn(),
trace: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
fatal: jest.fn(),
},
}
})
export { logger }

86
dht-node/tsconfig.json Normal file
View File

@ -0,0 +1,86 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./build", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
"strictPropertyInitialization": false, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
// "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"baseUrl": ".", /* Base directory to resolve non-absolute module names. */
"paths": { /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
"@/*": ["src/*"],
"@test/*": ["test/*"],
/* external */
"@typeorm/*": ["../backend/src/typeorm/*", "../../backend/src/typeorm/*"],
"@dbTools/*": ["../database/src/*", "../../database/build/src/*"],
"@entity/*": ["../database/entity/*", "../../database/build/entity/*"]
},
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
"typeRoots": ["src/dht_node/@types", "node_modules/@types"], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
"experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
"emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"references": [
{
"path": "../database/tsconfig.json",
// add 'prepend' if you want to include the referenced project in your output file
// "prepend": true
}
]
}

4320
dht-node/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -61,6 +61,29 @@ services:
- ./backend:/app
- ./database:/database
########################################################
# DHT-NODE #############################################
########################################################
dht-node:
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/dht-node:local-development
build:
target: development
networks:
- external-net
- internal-net
environment:
- NODE_ENV="development"
volumes:
# This makes sure the docker container has its own node modules.
# Therefore it is possible to have a different node version on the host machine
- dht_node_modules:/app/node_modules
- dht_database_node_modules:/database/node_modules
- dht_database_build:/database/build
# bind the local folder to the docker to allow live reload
- ./dht-node:/app
- ./database:/database
########################################################
# DATABASE ##############################################
########################################################
@ -129,5 +152,8 @@ volumes:
backend_node_modules:
backend_database_node_modules:
backend_database_build:
dht_node_modules:
dht_database_node_modules:
dht_database_build:
database_node_modules:
database_build:
database_build:

View File

@ -111,6 +111,42 @@ services:
# <host_machine_directory>:<container_directory> mirror bidirectional path in local context with path in Docker container
- ./logs/backend:/logs/backend
########################################################
# DHT-NODE #############################################
########################################################
dht-node:
# name the image so that it cannot be found in a DockerHub repository, otherwise it will not be built locally from the 'dockerfile' but pulled from there
image: gradido/dht-node:local-production
build:
# since we have to include the entities from ./database we cannot define the context as ./backend
# this might blow build image size to the moon ?!
context: ./
dockerfile: ./dht-node/Dockerfile
target: production
networks:
- internal-net
- external-net
#ports:
# - 5000:5000
depends_on:
- mariadb
restart: always
environment:
# Envs used in Dockerfile
# - DOCKER_WORKDIR="/app"
# - PORT=5000
- BUILD_DATE
- BUILD_VERSION
- BUILD_COMMIT
- NODE_ENV="production"
- DB_HOST=mariadb
# Application only envs
#env_file:
# - ./frontend/.env
volumes:
# <host_machine_directory>:<container_directory> mirror bidirectional path in local context with path in Docker container
- ./logs/dht-node:/logs/dht-node
########################################################
# DATABASE #############################################
########################################################

View File

@ -3,7 +3,7 @@
<b-form
ref="form"
@submit.prevent="submit"
class="p-3 bg-white appBoxShadow gradido-border-radius"
class="form-style p-3 bg-white appBoxShadow gradido-border-radius"
>
<label>{{ $t('contribution.selectDate') }}</label>
<b-form-datepicker
@ -23,50 +23,70 @@
<template #nav-next-year><span></span></template>
</b-form-datepicker>
<input-textarea
id="contribution-memo"
v-model="form.memo"
:name="$t('form.message')"
:label="$t('contribution.activity')"
:placeholder="$t('contribution.yourActivity')"
:rules="{ required: true, min: 5, max: 255 }"
/>
<input-hour
v-model="form.hours"
:name="$t('form.hours')"
:label="$t('form.hours')"
placeholder="0.25"
:rules="{
required: true,
min: 0.25,
max: validMaxTime,
gddCreationTime: [0.25, validMaxTime],
}"
:validMaxTime="validMaxTime"
@updateAmount="updateAmount"
></input-hour>
<input-amount
id="contribution-amount"
v-model="form.amount"
:name="$t('form.amount')"
:label="$t('form.amount')"
placeholder="20"
:rules="{ required: true, gddSendAmount: [20, validMaxGDD] }"
typ="ContributionForm"
></input-amount>
<div
v-if="(isThisMonth && maxGddThisMonth <= 0) || (!isThisMonth && maxGddLastMonth <= 0)"
class="p-3"
>
{{ noOpenCreation }}
</div>
<div v-else>
<input-textarea
id="contribution-memo"
v-model="form.memo"
:name="$t('form.message')"
:label="$t('contribution.activity')"
:placeholder="$t('contribution.yourActivity')"
:rules="{ required: true, min: 5, max: 255 }"
/>
<input-hour
v-model="form.hours"
:name="$t('form.hours')"
:label="$t('form.hours')"
placeholder="0.25"
:rules="{
required: true,
min: 0.25,
max: validMaxTime,
gddCreationTime: [0.25, validMaxTime],
}"
:validMaxTime="validMaxTime"
@updateAmount="updateAmount"
></input-hour>
<input-amount
id="contribution-amount"
v-model="form.amount"
:name="$t('form.amount')"
:label="$t('form.amount')"
placeholder="20"
:rules="{ required: true, gddSendAmount: [20, validMaxGDD] }"
typ="ContributionForm"
></input-amount>
<b-row class="mt-5">
<b-col>
<b-button type="reset" variant="secondary" @click="reset" data-test="button-cancel">
{{ $t('form.cancel') }}
</b-button>
</b-col>
<b-col class="text-right">
<b-button type="submit" variant="gradido" :disabled="disabled" data-test="button-submit">
{{ form.id ? $t('form.change') : $t('contribution.submit') }}
</b-button>
</b-col>
</b-row>
<b-row class="mt-5">
<b-col cols="12" lg="6">
<b-button
block
type="reset"
variant="secondary"
@click="reset"
data-test="button-cancel"
>
{{ $t('form.cancel') }}
</b-button>
</b-col>
<b-col cols="12" lg="6" class="text-right mt-4 mt-lg-0">
<b-button
block
type="submit"
variant="gradido"
:disabled="disabled"
data-test="button-submit"
>
{{ form.id ? $t('form.change') : $t('contribution.submit') }}
</b-button>
</b-col>
</b-row>
</div>
</b-form>
</div>
</template>
@ -133,6 +153,18 @@ export default {
validMaxTime() {
return Number(this.validMaxGDD / 20)
},
noOpenCreation() {
if (this.maxGddThisMonth <= 0 && this.maxGddLastMonth <= 0) {
return this.$t('contribution.noOpenCreation.allMonth')
}
if (this.isThisMonth && this.maxGddThisMonth <= 0) {
return this.$t('contribution.noOpenCreation.thisMonth')
}
if (!this.isThisMonth && this.maxGddLastMonth <= 0) {
return this.$t('contribution.noOpenCreation.lastMonth')
}
return ''
},
},
watch: {
value() {
@ -142,6 +174,9 @@ export default {
}
</script>
<style>
.form-style {
min-height: 410px;
}
span.errors {
color: red;
}

View File

@ -2,10 +2,10 @@
<div class="transaction-form">
<b-row>
<b-col cols="12">
<b-card class="appBoxShadow gradido-border-radius" body-class="p-3">
<b-card class="appBoxShadow gradido-border-radius" body-class="p-4">
<validation-observer v-slot="{ handleSubmit }" ref="formValidator">
<b-form role="form" @submit.prevent="handleSubmit(onSubmit)" @reset="onReset">
<b-form-radio-group v-model="radioSelected" class="container">
<b-form-radio-group v-model="radioSelected">
<b-row class="mb-4">
<b-col cols="12" lg="6">
<b-row class="bg-248 gradido-border-radius pt-lg-2 mr-lg-2">
@ -39,14 +39,13 @@
</b-row>
</b-col>
</b-row>
<div class="mt-4 mb-4" v-if="radioSelected === sendTypes.link">
<h2 class="alert-heading">{{ $t('gdd_per_link.header') }}</h2>
<div>
{{ $t('gdd_per_link.choose-amount') }}
</div>
</div>
</b-form-radio-group>
<div class="mt-4 mb-4" v-if="radioSelected === sendTypes.link">
<h2 class="alert-heading">{{ $t('gdd_per_link.header') }}</h2>
<div>
{{ $t('gdd_per_link.choose-amount') }}
</div>
</div>
<b-row>
<b-col>
<b-row>
@ -106,7 +105,10 @@
</b-col>
<b-col cols="12" md="6" lg="6" class="text-lg-right">
<b-button block type="submit" variant="gradido">
{{ $t('form.check_now') }}
<span v-if="radioSelected === sendTypes.send">{{ $t('form.check_now') }}</span>
<span v-if="radioSelected === sendTypes.link">
{{ $t('form.generate_now') }}
</span>
</b-button>
</b-col>
</b-row>

View File

@ -78,7 +78,24 @@ export default {
min-width: 200px;
}
@media screen and (max-width: 1024px) {
@media screen and (min-width: 1025px) {
#side-menu {
max-width: 180px;
}
#component-sidebar {
min-width: 180px;
}
}
@media screen and (min-width: 1075px) {
#side-menu {
max-width: 200px;
}
#component-sidebar {
min-width: 200px;
}
}
@media screen and (max-width: 1108px) {
#side-menu {
max-width: 100%;
}

View File

@ -51,13 +51,22 @@
"deleted": "Der Beitrag wurde gelöscht! Wird aber sichtbar bleiben.",
"formText": {
"bringYourTalentsTo": "Bring dich mit deinen Talenten in die Gemeinschaft ein! Dein freiwilliges Engagement honorieren wir mit 20 GDD pro Stunde bis maximal 1.000 GDD im Monat.",
"describeYourCommunity": "Beschreibe deine Gemeinwohl-Tätigkeit mit Angabe der Stunden und trage einen Betrag von 20 GDD pro Stunde ein! Nach Bestätigung durch einen Moderator wird der Betrag deinem Konto gutgeschrieben.",
"describeYourCommunity": "Beschreibe deine Gemeinwohl-Tätigkeit und gib die Anzahl der Stunden an! Der Betrag von 20 GDD pro Stunde wird automatisch berechnet. Nach Bestätigung durch einen Moderator wird der Betrag deinem Konto gutgeschrieben.",
"maxGDDforMonth": "Du kannst für den ausgewählten Monat nur noch maximal {amount} GDD einreichen.",
"openAmountForMonth": "Für <b>{monthAndYear}</b> kannst du noch <b>{creation}</b> GDD einreichen.",
"yourContribution": "Dein Beitrag zum Gemeinwohl"
},
"lastContribution": "Letzte Beiträge",
"noContributions": {
"allContributions": "Es wurden noch keine Beiträge eingereicht.",
"myContributions": "Du hast noch keine Beiträge eingereicht."
},
"noDateSelected": "Wähle irgendein Datum im Monat",
"noOpenCreation": {
"allMonth": "Für alle beiden Monate ist dein Schöpfungslimit erreicht. Den Nächsten Monat kannst du wieder 1000 GDD Schöpfen.",
"lastMonth": "Für den ausgewählten Monat ist das Schöpfungslimit erreicht.",
"thisMonth": "Für den aktuellen Monat ist das Schöpfungslimit erreicht."
},
"selectDate": "Wann war dein Beitrag?",
"submit": "Einreichen",
"submitted": "Der Beitrag wurde eingereicht.",
@ -251,7 +260,7 @@
},
"openHours": "Offene Stunden",
"pageTitle": {
"community": "Meine Gemeinschaft",
"community": "Gradido schöpfen",
"gdt": "Deine GDT Transaktionen",
"information": "{community}",
"overview": "Willkommen {name}",

View File

@ -50,14 +50,23 @@
"delete": "Delete Contribution! Are you sure?",
"deleted": "The contribution has been deleted! But it will remain visible.",
"formText": {
"bringYourTalentsTo": "Bring your talents to the community! Your voluntary commitment will be rewarded with 20 GDD per hour up to a maximum of 1,000 GDD per month.",
"describeYourCommunity": "Describe your community service activity with hours and enter an amount of 20 GDD per hour! After confirmation by a moderator, the amount will be credited to your account.",
"bringYourTalentsTo": "Bring your talents to the community! We reward your voluntary commitment with 20 GDD per hour up to a maximum of 1,000 GDD per month.",
"describeYourCommunity": "Describe your community service activity and specify the number of hours! The amount of 20 GDD per hour will be calculated automatically. After confirmation by a moderator, the amount will be credited to your account.",
"maxGDDforMonth": "You can only submit a maximum of {amount} GDD for the selected month.",
"openAmountForMonth": "For <b>{monthAndYear}</b>, you can still submit <b>{creation}</b> GDD.",
"yourContribution": "Your contribution to the common good"
"yourContribution": "Your Contributions to the Common Good"
},
"lastContribution": "Last Contributions",
"noContributions": {
"allContributions": "No contributions have been submitted yet.",
"myContributions": "You have not submitted any entries yet."
},
"noDateSelected": "Choose any date in the month",
"noOpenCreation": {
"allMonth": "For all two months your creation limit is reached. The next month you can create 1000 GDD again.",
"lastMonth": "The creation limit is reached for the selected month.",
"thisMonth": "The creation limit has been reached for the current month."
},
"selectDate": "When was your contribution?",
"submit": "Submit",
"submitted": "The contribution was submitted.",
@ -251,7 +260,7 @@
},
"openHours": "Open Hours",
"pageTitle": {
"community": "My community",
"community": "Create Gradido",
"gdt": "Your GDT transactions",
"information": "{community}",
"overview": "Welcome {name}",

View File

@ -20,28 +20,38 @@
/>
</b-tab>
<b-tab no-body>
<contribution-list
@closeAllOpenCollapse="closeAllOpenCollapse"
:items="items"
@update-list-contributions="updateListContributions"
@update-contribution-form="updateContributionForm"
@delete-contribution="deleteContribution"
@update-state="updateState"
:contributionCount="contributionCount"
:showPagination="true"
:pageSize="pageSize"
/>
<div v-if="items.length === 0">
{{ $t('contribution.noContributions.myContributions') }}
</div>
<div v-else>
<contribution-list
@closeAllOpenCollapse="closeAllOpenCollapse"
:items="items"
@update-list-contributions="updateListContributions"
@update-contribution-form="updateContributionForm"
@delete-contribution="deleteContribution"
@update-state="updateState"
:contributionCount="contributionCount"
:showPagination="true"
:pageSize="pageSize"
/>
</div>
</b-tab>
<b-tab no-body>
<contribution-list
:items="itemsAll"
@update-list-contributions="updateListAllContributions"
@update-contribution-form="updateContributionForm"
:contributionCount="contributionCountAll"
:showPagination="true"
:pageSize="pageSizeAll"
:allContribution="true"
/>
<div v-if="itemsAll.length === 0">
{{ $t('contribution.noContributions.allContributions') }}
</div>
<div v-else>
<contribution-list
:items="itemsAll"
@update-list-contributions="updateListAllContributions"
@update-contribution-form="updateContributionForm"
:contributionCount="contributionCountAll"
:showPagination="true"
:pageSize="pageSizeAll"
:allContribution="true"
/>
</div>
</b-tab>
</b-tabs>
</div>
@ -296,8 +306,8 @@ export default {
},
created() {
this.updateTransactions(0)
this.tabIndex = 1
this.$router.push({ path: '/community#my' })
this.tabIndex = 0
this.$router.push({ path: '/community#edit' })
},
}
</script>