diff --git a/.github/workflows/test_database.yml b/.github/workflows/test_database.yml index ac313ff34..a234c7eec 100644 --- a/.github/workflows/test_database.yml +++ b/.github/workflows/test_database.yml @@ -42,6 +42,11 @@ jobs: - name: Checkout code uses: actions/checkout@v3 + - name: Set Node.js version + uses: actions/setup-node@v4 + with: + node-version: '18.20.7' + - name: Database | docker-compose run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb diff --git a/.github/workflows/test_e2e.yml b/.github/workflows/test_e2e.yml index 0f1fe278c..50de7090b 100644 --- a/.github/workflows/test_e2e.yml +++ b/.github/workflows/test_e2e.yml @@ -9,6 +9,11 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 + + - name: Set Node.js version + uses: actions/setup-node@v4 + with: + node-version: '18.20.7' - name: install bun uses: oven-sh/setup-bun@v2 diff --git a/.nvmrc b/.nvmrc index 5a0afb48b..216afccff 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.20.7 \ No newline at end of file +v18.20.7 diff --git a/backend/@types/random-bigint/index.d.ts b/backend/@types/random-bigint/index.d.ts index 0f685e722..9692fcbf7 100644 --- a/backend/@types/random-bigint/index.d.ts +++ b/backend/@types/random-bigint/index.d.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/ban-types */ + declare module 'random-bigint' { function random(bits: number, cb?: (err: Error, num: BigInt) => void): BigInt export = random diff --git a/backend/@types/sodium-native/index.d.ts b/backend/@types/sodium-native/index.d.ts index 773d85ee5..ec6ebc07b 100644 --- a/backend/@types/sodium-native/index.d.ts +++ b/backend/@types/sodium-native/index.d.ts @@ -1,4 +1,4 @@ -// eslint-disable-next-line import/no-unresolved + export * from '@/node_modules/@types/sodium-native' declare module 'sodium-native' { diff --git a/backend/jest.config.js b/backend/jest.config.js index 1de74762b..87f32599d 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -1,5 +1,4 @@ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ -// eslint-disable-next-line import/no-commonjs, import/unambiguous module.exports = { verbose: true, preset: 'ts-jest', @@ -7,7 +6,7 @@ module.exports = { collectCoverageFrom: ['src/**/*.ts', '!**/node_modules/**', '!src/seeds/**', '!build/**'], coverageThreshold: { global: { - lines: 77, + lines: 75, }, }, setupFiles: ['/test/testSetup.ts'], @@ -25,22 +24,18 @@ module.exports = { '@typeorm/(.*)': '/src/typeorm/$1', '@test/(.*)': '/test/$1', '@entity/(.*)': - // eslint-disable-next-line n/no-process-env process.env.NODE_ENV === 'development' ? '/../database/entity/$1' : '/../database/build/entity/$1', '@logging/(.*)': - // eslint-disable-next-line n/no-process-env process.env.NODE_ENV === 'development' ? '/../database/logging/$1' : '/../database/build/logging/$1', '@dbTools/(.*)': - // eslint-disable-next-line n/no-process-env process.env.NODE_ENV === 'development' ? '/../database/src/$1' : '/../database/build/src/$1', '@config/(.*)': - // eslint-disable-next-line n/no-process-env process.env.NODE_ENV === 'development' ? '/../config/src/$1' : '/../config/build/$1', diff --git a/backend/package.json b/backend/package.json index cb3681548..490f7aa0c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,15 +11,16 @@ "build": "ts-node ./esbuild.config.ts && mkdirp build/templates/ && ncp src/emails/templates build/templates && mkdirp locales/ && ncp src/locales locales", "clean": "tsc --build --clean", "dev": "cross-env TZ=UTC nodemon -w src --ext ts,pug,json,css -r tsconfig-paths/register src/index.ts", + "test": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_backend jest --runInBand --forceExit --detectOpenHandles", + "seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts", + "klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/executeKlicktipp.ts", "gmsusers": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/gms/ExportUsers.ts", "humhubUserExport": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/apis/humhub/ExportUsers.ts", - "klicktipp": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/util/executeKlicktipp.ts", "lint": "biome check --error-on-warnings .", "lint:fix": "biome check --error-on-warnings . --write", + "lint:fix:unsafe": "biome check --fix --unsafe", "locales": "scripts/sort.sh", - "seed": "cross-env TZ=UTC NODE_ENV=development ts-node -r tsconfig-paths/register src/seeds/index.ts", "start": "cross-env TZ=UTC NODE_ENV=production node build/index.js", - "test": "cross-env TZ=UTC NODE_ENV=development DB_DATABASE=gradido_test_backend jest --runInBand --forceExit --detectOpenHandles", "typecheck": "tsc --noEmit" }, "nodemonConfig": { diff --git a/backend/src/apis/humhub/model/Account.ts b/backend/src/apis/humhub/model/Account.ts index 636ae36c5..2aeacc612 100644 --- a/backend/src/apis/humhub/model/Account.ts +++ b/backend/src/apis/humhub/model/Account.ts @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ import { User } from 'database' import { convertGradidoLanguageToHumhub } from '@/apis/humhub/convertLanguage' diff --git a/backend/src/apis/humhub/syncUser.ts b/backend/src/apis/humhub/syncUser.ts index ba03df530..1e62871be 100644 --- a/backend/src/apis/humhub/syncUser.ts +++ b/backend/src/apis/humhub/syncUser.ts @@ -49,7 +49,11 @@ export async function syncUser( if (!isValid(postUser, user.id)) { return ExecutedHumhubAction.VALIDATION_ERROR } - const humhubUser = humhubUsers.get(postUser.account.username) + let humhubUser = humhubUsers.get(postUser.account.username) + if (!humhubUser) { + // fallback for legacy users + humhubUser = humhubUsers.get(user.gradidoID) + } const humHubClient = HumHubClient.getInstance() if (!humHubClient) { throw new LogError('Error creating humhub client') diff --git a/backend/src/auth/INALIENABLE_RIGHTS.ts b/backend/src/auth/INALIENABLE_RIGHTS.ts index c3c96b95e..19865608f 100644 --- a/backend/src/auth/INALIENABLE_RIGHTS.ts +++ b/backend/src/auth/INALIENABLE_RIGHTS.ts @@ -7,6 +7,7 @@ export const INALIENABLE_RIGHTS = [ RIGHTS.SEND_RESET_PASSWORD_EMAIL, RIGHTS.SET_PASSWORD, RIGHTS.QUERY_TRANSACTION_LINK, + RIGHTS.QUERY_REDEEM_JWT, RIGHTS.QUERY_OPT_IN, RIGHTS.CHECK_USERNAME, RIGHTS.PROJECT_BRANDING_BANNER, diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index 0ccb9695f..012a4e627 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -6,6 +6,7 @@ export enum RIGHTS { SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL', SET_PASSWORD = 'SET_PASSWORD', QUERY_TRANSACTION_LINK = 'QUERY_TRANSACTION_LINK', + QUERY_REDEEM_JWT = 'QUERY_REDEEM_JWT', QUERY_OPT_IN = 'QUERY_OPT_IN', CHECK_USERNAME = 'CHECK_USERNAME', PROJECT_BRANDING_BANNER = 'PROJECT_BRANDING_BANNER', @@ -24,6 +25,7 @@ export enum RIGHTS { CREATE_TRANSACTION_LINK = 'CREATE_TRANSACTION_LINK', DELETE_TRANSACTION_LINK = 'DELETE_TRANSACTION_LINK', REDEEM_TRANSACTION_LINK = 'REDEEM_TRANSACTION_LINK', + DISBURSE_TRANSACTION_LINK = 'DISBURSE_TRANSACTION_LINK', LIST_TRANSACTION_LINKS = 'LIST_TRANSACTION_LINKS', GDT_BALANCE = 'GDT_BALANCE', CREATE_CONTRIBUTION = 'CREATE_CONTRIBUTION', diff --git a/backend/src/auth/USER_RIGHTS.ts b/backend/src/auth/USER_RIGHTS.ts index 2d0a4d980..3f08d1160 100644 --- a/backend/src/auth/USER_RIGHTS.ts +++ b/backend/src/auth/USER_RIGHTS.ts @@ -15,6 +15,7 @@ export const USER_RIGHTS = [ RIGHTS.CREATE_TRANSACTION_LINK, RIGHTS.DELETE_TRANSACTION_LINK, RIGHTS.REDEEM_TRANSACTION_LINK, + RIGHTS.DISBURSE_TRANSACTION_LINK, RIGHTS.LIST_TRANSACTION_LINKS, RIGHTS.GDT_BALANCE, RIGHTS.CREATE_CONTRIBUTION, diff --git a/backend/src/auth/jwt/JWT.ts b/backend/src/auth/jwt/JWT.ts new file mode 100644 index 000000000..6f6581773 --- /dev/null +++ b/backend/src/auth/jwt/JWT.ts @@ -0,0 +1,70 @@ +import { createPrivateKey, sign } from 'node:crypto' + +import { JWTPayload, SignJWT, decodeJwt, jwtVerify } from 'jose' + +import { LogError } from '@/server/LogError' +import { backendLogger as logger } from '@/server/logger' + +import { JwtPayloadType } from './payloadtypes/JwtPayloadType' + +export const verify = async (token: string, signkey: string): Promise => { + if (!token) { + throw new LogError('401 Unauthorized') + } + logger.info('JWT.verify... token, signkey=', token, signkey) + + try { + /* + const { KeyObject } = await import('node:crypto') + const cryptoKey = await crypto.subtle.importKey('raw', signkey, { name: 'RS256' }, false, [ + 'sign', + ]) + const keyObject = KeyObject.from(cryptoKey) + logger.info('JWT.verify... keyObject=', keyObject) + logger.info('JWT.verify... keyObject.asymmetricKeyDetails=', keyObject.asymmetricKeyDetails) + logger.info('JWT.verify... keyObject.asymmetricKeyType=', keyObject.asymmetricKeyType) + logger.info('JWT.verify... keyObject.asymmetricKeySize=', keyObject.asymmetricKeySize) + */ + const secret = new TextEncoder().encode(signkey) + const { payload } = await jwtVerify(token, secret, { + issuer: 'urn:gradido:issuer', + audience: 'urn:gradido:audience', + }) + logger.info('JWT.verify after jwtVerify... payload=', payload) + return payload as JwtPayloadType + } catch (err) { + logger.error('JWT.verify after jwtVerify... error=', err) + return null + } +} + +export const encode = async (payload: JwtPayloadType, signkey: string): Promise => { + logger.info('JWT.encode... payload=', payload) + logger.info('JWT.encode... signkey=', signkey) + try { + const secret = new TextEncoder().encode(signkey) + const token = await new SignJWT({ payload, 'urn:gradido:claim': true }) + .setProtectedHeader({ + alg: 'HS256', + }) + .setIssuedAt() + .setIssuer('urn:gradido:issuer') + .setAudience('urn:gradido:audience') + .setExpirationTime(payload.expiration) + .sign(secret) + return token + } catch (e) { + logger.error('Failed to sign JWT:', e) + throw e + } +} + +export const verifyJwtType = async (token: string, signkey: string): Promise => { + const payload = await verify(token, signkey) + return payload ? payload.tokentype : 'unknown token type' +} + +export const decode = (token: string): JwtPayloadType => { + const { payload } = decodeJwt(token) + return payload as JwtPayloadType +} diff --git a/backend/src/auth/jwt/payloadtypes/DisburseJwtPayloadType.ts b/backend/src/auth/jwt/payloadtypes/DisburseJwtPayloadType.ts new file mode 100644 index 000000000..16a029d2d --- /dev/null +++ b/backend/src/auth/jwt/payloadtypes/DisburseJwtPayloadType.ts @@ -0,0 +1,48 @@ +// import { JWTPayload } from 'jose' +import { JwtPayloadType } from './JwtPayloadType' + +export class DisburseJwtPayloadType extends JwtPayloadType { + static DISBURSE_ACTIVATION_TYPE = 'disburse-activation' + + sendercommunityuuid: string + sendergradidoid: string + recipientcommunityuuid: string + recipientcommunityname: string + recipientgradidoid: string + recipientfirstname: string + code: string + amount: string + memo: string + validuntil: string + recipientalias: string + + constructor( + senderCommunityUuid: string, + senderGradidoId: string, + recipientCommunityUuid: string, + recipientCommunityName: string, + recipientGradidoId: string, + recipientFirstName: string, + code: string, + amount: string, + memo: string, + validUntil: string, + recipientAlias: string, + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + super() + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.tokentype = DisburseJwtPayloadType.DISBURSE_ACTIVATION_TYPE + this.sendercommunityuuid = senderCommunityUuid + this.sendergradidoid = senderGradidoId + this.recipientcommunityuuid = recipientCommunityUuid + this.recipientcommunityname = recipientCommunityName + this.recipientgradidoid = recipientGradidoId + this.recipientfirstname = recipientFirstName + this.code = code + this.amount = amount + this.memo = memo + this.validuntil = validUntil + this.recipientalias = recipientAlias + } +} diff --git a/backend/src/auth/jwt/payloadtypes/JwtPayloadType.ts b/backend/src/auth/jwt/payloadtypes/JwtPayloadType.ts new file mode 100644 index 000000000..48881ee32 --- /dev/null +++ b/backend/src/auth/jwt/payloadtypes/JwtPayloadType.ts @@ -0,0 +1,21 @@ +import { JWTPayload } from 'jose' + +import { CONFIG } from '@/config' + +export class JwtPayloadType implements JWTPayload { + iat?: number | undefined + exp?: number | undefined + nbf?: number | undefined + jti?: string | undefined + aud?: string | string[] | undefined + sub?: string | undefined + iss?: string | undefined; + [propName: string]: unknown + + tokentype: string + expiration: string // in minutes (format: 10m for ten minutes) + constructor() { + this.tokentype = 'unknown jwt type' + this.expiration = CONFIG.REDEEM_JWT_TOKEN_EXPIRATION || '10m' + } +} diff --git a/backend/src/auth/jwt/payloadtypes/RedeemJwtPayloadType.ts b/backend/src/auth/jwt/payloadtypes/RedeemJwtPayloadType.ts new file mode 100644 index 000000000..7c9af45e2 --- /dev/null +++ b/backend/src/auth/jwt/payloadtypes/RedeemJwtPayloadType.ts @@ -0,0 +1,36 @@ +// import { JWTPayload } from 'jose' +import { JwtPayloadType } from './JwtPayloadType' + +export class RedeemJwtPayloadType extends JwtPayloadType { + static REDEEM_ACTIVATION_TYPE = 'redeem-activation' + + sendercommunityuuid: string + sendergradidoid: string + sendername: string // alias or firstname + redeemcode: string + amount: string + memo: string + validuntil: string + + constructor( + senderCom: string, + senderUser: string, + sendername: string, + code: string, + amount: string, + memo: string, + validUntil: string, + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + super() + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.tokentype = RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE + this.sendercommunityuuid = senderCom + this.sendergradidoid = senderUser + this.sendername = sendername + this.redeemcode = code + this.amount = amount + this.memo = memo + this.validuntil = validUntil + } +} diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 10688a236..2eba8c8c1 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,6 +1,5 @@ // ATTENTION: DO NOT PUT ANY SECRETS IN HERE (or the .env) -// eslint-disable-next-line import/no-unresolved import { validate } from 'config-schema' import { latestDbVersion } from 'database' import { Decimal } from 'decimal.js-light' @@ -26,6 +25,7 @@ const server = { PORT: process.env.PORT ?? 4000, JWT_SECRET: process.env.JWT_SECRET ?? 'secret123', JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '10m', + REDEEM_JWT_TOKEN_EXPIRATION: process.env.REDEEM_JWT_TOKEN_EXPIRATION ?? '10m', GRAPHIQL: process.env.GRAPHIQL === 'true' || false, GDT_ACTIVE: process.env.GDT_ACTIVE === 'true' || false, GDT_API_URL: process.env.GDT_API_URL ?? 'https://gdt.gradido.net', @@ -35,8 +35,14 @@ const server = { } const database = { + DB_CONNECT_RETRY_COUNT: process.env.DB_CONNECT_RETRY_COUNT + ? Number.parseInt(process.env.DB_CONNECT_RETRY_COUNT) + : 15, + DB_CONNECT_RETRY_DELAY_MS: process.env.DB_CONNECT_RETRY_DELAY_MS + ? Number.parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS) + : 500, DB_HOST: process.env.DB_HOST ?? 'localhost', - DB_PORT: process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306, + DB_PORT: process.env.DB_PORT ? Number.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', diff --git a/backend/src/config/schema.ts b/backend/src/config/schema.ts index a6ed60ea1..4bfe2a551 100644 --- a/backend/src/config/schema.ts +++ b/backend/src/config/schema.ts @@ -3,6 +3,8 @@ import { COMMUNITY_NAME, COMMUNITY_SUPPORT_MAIL, COMMUNITY_URL, + DB_CONNECT_RETRY_COUNT, + DB_CONNECT_RETRY_DELAY_MS, DB_DATABASE, DB_HOST, DB_PASSWORD, @@ -38,6 +40,8 @@ export const schema = Joi.object({ DB_USER, DB_VERSION, DB_DATABASE, + DB_CONNECT_RETRY_COUNT, + DB_CONNECT_RETRY_DELAY_MS, DECAY_START_TIME, GDT_API_URL, GDT_ACTIVE, @@ -363,5 +367,20 @@ export const schema = Joi.object({ .required() .description('Time for JWT token to expire, auto logout'), + REDEEM_JWT_TOKEN_EXPIRATION: Joi.alternatives() + .try( + Joi.string() + .pattern(/^\d+[smhdw]$/) + .description( + 'Expiration time for x-community redeem JWT token, in format like "10m", "1h", "1d"', + ) + .default('10m'), + Joi.number() + .positive() + .description('Expiration time for x-community redeem JWT token in minutes'), + ) + .required() + .description('Time for x-community redeem JWT token to expire'), + WEBHOOK_ELOPAGE_SECRET: Joi.string().description("isn't really used any more").optional(), }) diff --git a/backend/src/federation/client/1_0/FederationClient.ts b/backend/src/federation/client/1_0/FederationClient.ts index ca7b07c9a..b83da8a8b 100644 --- a/backend/src/federation/client/1_0/FederationClient.ts +++ b/backend/src/federation/client/1_0/FederationClient.ts @@ -78,6 +78,7 @@ export class FederationClient { ) return data.getPublicCommunityInfo } catch (err) { + logger.warn(' err', err) const errorString = JSON.stringify(err) logger.warn('Federation: getPublicCommunityInfo failed for endpoint', { endpoint: this.endpoint, diff --git a/backend/src/graphql/model/RedeemJwtLink.ts b/backend/src/graphql/model/RedeemJwtLink.ts new file mode 100644 index 000000000..018a92bb0 --- /dev/null +++ b/backend/src/graphql/model/RedeemJwtLink.ts @@ -0,0 +1,55 @@ +import { Decimal } from 'decimal.js-light' +import { Field, ObjectType } from 'type-graphql' + +import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType' + +import { Community } from './Community' +import { User } from './User' + +@ObjectType() +export class RedeemJwtLink { + constructor( + redeemJwtPayload: RedeemJwtPayloadType, + senderCommunity: Community, + senderUser: User, + recipientCommunity: Community, + recipientUser?: User, + ) { + this.senderCommunity = senderCommunity + this.recipientCommunity = recipientCommunity + this.senderUser = senderUser + if (recipientUser !== undefined) { + this.recipientUser = recipientUser + } else { + this.recipientUser = null + } + this.amount = new Decimal(redeemJwtPayload.amount) + this.memo = redeemJwtPayload.memo + this.code = redeemJwtPayload.redeemcode + this.validUntil = new Date(redeemJwtPayload.validuntil) + } + + @Field(() => Community) + senderCommunity: Community + + @Field(() => User) + senderUser: User + + @Field(() => Community) + recipientCommunity: Community + + @Field(() => User, { nullable: true }) + recipientUser: User | null + + @Field(() => Decimal) + amount: Decimal + + @Field(() => String) + memo: string + + @Field(() => String) + code: string + + @Field(() => Date) + validUntil: Date +} diff --git a/backend/src/graphql/model/TransactionLink.ts b/backend/src/graphql/model/TransactionLink.ts index 6799dd859..8262b1264 100644 --- a/backend/src/graphql/model/TransactionLink.ts +++ b/backend/src/graphql/model/TransactionLink.ts @@ -1,33 +1,48 @@ -import { TransactionLink as dbTransactionLink } from 'database' +import { Community as DbCommunity, TransactionLink as DbTransactionLink } from 'database' import { Decimal } from 'decimal.js-light' import { Field, Int, ObjectType } from 'type-graphql' import { CONFIG } from '@/config' +import { Community } from './Community' import { User } from './User' @ObjectType() export class TransactionLink { - constructor(transactionLink: dbTransactionLink, user: User, redeemedBy: User | null = null) { - this.id = transactionLink.id - this.user = user - this.amount = transactionLink.amount - this.holdAvailableAmount = transactionLink.holdAvailableAmount - this.memo = transactionLink.memo - this.code = transactionLink.code - this.createdAt = transactionLink.createdAt - this.validUntil = transactionLink.validUntil - this.deletedAt = transactionLink.deletedAt - this.redeemedAt = transactionLink.redeemedAt - this.redeemedBy = redeemedBy - this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code + constructor( + dbTransactionLink?: DbTransactionLink, + user?: User, + redeemedBy?: User, + dbCommunities?: DbCommunity[], + ) { + if (dbTransactionLink !== undefined) { + this.id = dbTransactionLink.id + this.amount = dbTransactionLink.amount + this.holdAvailableAmount = dbTransactionLink.holdAvailableAmount + this.memo = dbTransactionLink.memo + this.code = dbTransactionLink.code + this.link = CONFIG.COMMUNITY_REDEEM_URL + this.code + this.createdAt = dbTransactionLink.createdAt + this.validUntil = dbTransactionLink.validUntil + this.deletedAt = dbTransactionLink.deletedAt + this.redeemedAt = dbTransactionLink.redeemedAt + } + if (user !== undefined) { + this.senderUser = user + } + if (redeemedBy !== undefined) { + this.redeemedBy = redeemedBy + } + if (dbCommunities !== undefined) { + this.communities = dbCommunities.map((dbCom: DbCommunity) => new Community(dbCom)) + } } @Field(() => Int) id: number @Field(() => User) - user: User + senderUser: User @Field(() => Decimal) amount: Decimal @@ -58,6 +73,12 @@ export class TransactionLink { @Field(() => String) link: string + + @Field(() => String) + communityName: string + + @Field(() => [Community]) + communities: Community[] } @ObjectType() diff --git a/backend/src/graphql/model/User.ts b/backend/src/graphql/model/User.ts index ac4b0bb08..ffc6aae31 100644 --- a/backend/src/graphql/model/User.ts +++ b/backend/src/graphql/model/User.ts @@ -1,4 +1,4 @@ -import { User as dbUser } from 'database' +import { User as DbUser } from 'database' import { Field, Int, ObjectType } from 'type-graphql' import { Point } from 'typeorm' @@ -14,43 +14,43 @@ import { UserContact } from './UserContact' @ObjectType() export class User { - constructor(user: dbUser | null) { - if (user) { - this.id = user.id - this.foreign = user.foreign - this.communityUuid = user.communityUuid - if (user.community) { - this.communityName = user.community.name + constructor(dbUser: DbUser | null) { + if (dbUser) { + this.id = dbUser.id + this.foreign = dbUser.foreign + this.communityUuid = dbUser.communityUuid + if (dbUser.community) { + this.communityName = dbUser.community.name } - this.gradidoID = user.gradidoID - this.alias = user.alias + this.gradidoID = dbUser.gradidoID + this.alias = dbUser.alias - const publishNameLogic = new PublishNameLogic(user) - const publishNameType = user.humhubPublishName as PublishNameType + const publishNameLogic = new PublishNameLogic(dbUser) + const publishNameType = dbUser.humhubPublishName as PublishNameType this.publicName = publishNameLogic.getPublicName(publishNameType) this.userIdentifier = publishNameLogic.getUserIdentifier(publishNameType) - if (user.emailContact) { - this.emailChecked = user.emailContact.emailChecked - this.emailContact = new UserContact(user.emailContact) + if (dbUser.emailContact) { + this.emailChecked = dbUser.emailContact.emailChecked + this.emailContact = new UserContact(dbUser.emailContact) } - this.firstName = user.firstName - this.lastName = user.lastName - this.deletedAt = user.deletedAt - this.createdAt = user.createdAt - this.language = user.language - this.publisherId = user.publisherId - this.roles = user.userRoles?.map((userRole) => userRole.role) ?? [] + this.firstName = dbUser.firstName + this.lastName = dbUser.lastName + this.deletedAt = dbUser.deletedAt + this.createdAt = dbUser.createdAt + this.language = dbUser.language + this.publisherId = dbUser.publisherId + this.roles = dbUser.userRoles?.map((userRole) => userRole.role) ?? [] this.klickTipp = null this.hasElopage = null - this.hideAmountGDD = user.hideAmountGDD - this.hideAmountGDT = user.hideAmountGDT - this.humhubAllowed = user.humhubAllowed - this.gmsAllowed = user.gmsAllowed - this.gmsPublishName = user.gmsPublishName - this.humhubPublishName = user.humhubPublishName - this.gmsPublishLocation = user.gmsPublishLocation - this.userLocation = user.location ? Point2Location(user.location as Point) : null + this.hideAmountGDD = dbUser.hideAmountGDD + this.hideAmountGDT = dbUser.hideAmountGDT + this.humhubAllowed = dbUser.humhubAllowed + this.gmsAllowed = dbUser.gmsAllowed + this.gmsPublishName = dbUser.gmsPublishName + this.humhubPublishName = dbUser.humhubPublishName + this.gmsPublishLocation = dbUser.gmsPublishLocation + this.userLocation = dbUser.location ? Point2Location(dbUser.location as Point) : null } } diff --git a/backend/src/graphql/resolver/TransactionLinkResolver.ts b/backend/src/graphql/resolver/TransactionLinkResolver.ts index 110be8966..0f375b387 100644 --- a/backend/src/graphql/resolver/TransactionLinkResolver.ts +++ b/backend/src/graphql/resolver/TransactionLinkResolver.ts @@ -1,5 +1,19 @@ import { randomBytes } from 'crypto' +import { Paginated } from '@arg/Paginated' +import { TransactionLinkArgs } from '@arg/TransactionLinkArgs' +import { TransactionLinkFilters } from '@arg/TransactionLinkFilters' +import { ContributionCycleType } from '@enum/ContributionCycleType' +import { ContributionStatus } from '@enum/ContributionStatus' +import { ContributionType } from '@enum/ContributionType' +import { TransactionTypeId } from '@enum/TransactionTypeId' +import { Community } from '@model/Community' +import { ContributionLink } from '@model/ContributionLink' +import { Decay } from '@model/Decay' +import { RedeemJwtLink } from '@model/RedeemJwtLink' +import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' +import { User } from '@model/User' +import { QueryLinkResult } from '@union/QueryLinkResult' import { Contribution as DbContribution, ContributionLink as DbContributionLink, @@ -11,20 +25,9 @@ import { Decimal } from 'decimal.js-light' import { Arg, Args, Authorized, Ctx, Int, Mutation, Query, Resolver } from 'type-graphql' import { getConnection } from 'typeorm' -import { Paginated } from '@arg/Paginated' -import { TransactionLinkArgs } from '@arg/TransactionLinkArgs' -import { TransactionLinkFilters } from '@arg/TransactionLinkFilters' -import { ContributionCycleType } from '@enum/ContributionCycleType' -import { ContributionStatus } from '@enum/ContributionStatus' -import { ContributionType } from '@enum/ContributionType' -import { TransactionTypeId } from '@enum/TransactionTypeId' -import { ContributionLink } from '@model/ContributionLink' -import { Decay } from '@model/Decay' -import { TransactionLink, TransactionLinkResult } from '@model/TransactionLink' -import { User } from '@model/User' -import { QueryLinkResult } from '@union/QueryLinkResult' - import { RIGHTS } from '@/auth/RIGHTS' +import { decode, encode, verify } from '@/auth/jwt/JWT' +import { RedeemJwtPayloadType } from '@/auth/jwt/payloadtypes/RedeemJwtPayloadType' import { EVENT_CONTRIBUTION_LINK_REDEEM, EVENT_TRANSACTION_LINK_CREATE, @@ -40,7 +43,13 @@ import { calculateDecay } from '@/util/decay' import { fullName } from '@/util/utilities' import { calculateBalance } from '@/util/validate' +import { DisburseJwtPayloadType } from '@/auth/jwt/payloadtypes/DisburseJwtPayloadType' import { executeTransaction } from './TransactionResolver' +import { + getAuthenticatedCommunities, + getCommunityByUuid, + getHomeCommunity, +} from './util/communities' import { getUserCreation, validateContribution } from './util/creations' import { getLastTransaction } from './util/getLastTransaction' import { sendTransactionsToDltConnector } from './util/sendTransactionsToDltConnector' @@ -138,6 +147,7 @@ export class TransactionLinkResolver { @Authorized([RIGHTS.QUERY_TRANSACTION_LINK]) @Query(() => QueryLinkResult) async queryTransactionLink(@Arg('code') code: string): Promise { + logger.debug('TransactionLinkResolver.queryTransactionLink... code=', code) if (code.match(/^CL-/)) { const contributionLink = await DbContributionLink.findOneOrFail({ where: { code: code.replace('CL-', '') }, @@ -145,18 +155,36 @@ export class TransactionLinkResolver { }) return new ContributionLink(contributionLink) } else { - const transactionLink = await DbTransactionLink.findOneOrFail({ - where: { code }, - withDeleted: true, - }) - const user = await DbUser.findOneOrFail({ where: { id: transactionLink.userId } }) - let redeemedBy: User | null = null - if (transactionLink?.redeemedBy) { - redeemedBy = new User( - await DbUser.findOneOrFail({ where: { id: transactionLink.redeemedBy } }), - ) + let txLinkFound = false + let dbTransactionLink!: DbTransactionLink + try { + dbTransactionLink = await DbTransactionLink.findOneOrFail({ + where: { code }, + withDeleted: true, + }) + txLinkFound = true + } catch (_err) { + txLinkFound = false + } + // normal redeem code + if (txLinkFound) { + logger.debug( + 'TransactionLinkResolver.queryTransactionLink... normal redeem code found=', + txLinkFound, + ) + const user = await DbUser.findOneOrFail({ where: { id: dbTransactionLink.userId } }) + let redeemedBy + if (dbTransactionLink.redeemedBy) { + redeemedBy = new User( + await DbUser.findOneOrFail({ where: { id: dbTransactionLink.redeemedBy } }), + ) + } + const communities = await getAuthenticatedCommunities() + return new TransactionLink(dbTransactionLink, new User(user), redeemedBy, communities) + } else { + // redeem jwt-token + return await this.queryRedeemJwtLink(code) } - return new TransactionLink(transactionLink, new User(user), redeemedBy) } } @@ -169,7 +197,6 @@ export class TransactionLinkResolver { const clientTimezoneOffset = getClientTimezoneOffset(context) // const homeCom = await DbCommunity.findOneOrFail({ where: { foreign: false } }) const user = getUser(context) - if (code.match(/^CL-/)) { // acquire lock const releaseLock = await TRANSACTIONS_LOCK.acquire() @@ -366,6 +393,104 @@ export class TransactionLinkResolver { } } + @Authorized([RIGHTS.QUERY_REDEEM_JWT]) + @Mutation(() => String) + async createRedeemJwt( + @Arg('gradidoId') gradidoId: string, + @Arg('senderCommunityUuid') senderCommunityUuid: string, + @Arg('senderCommunityName') senderCommunityName: string, + @Arg('recipientCommunityUuid') recipientCommunityUuid: string, + @Arg('code') code: string, + @Arg('amount') amount: string, + @Arg('memo') memo: string, + @Arg('firstName', { nullable: true }) firstName?: string, + @Arg('alias', { nullable: true }) alias?: string, + @Arg('validUntil', { nullable: true }) validUntil?: string, + ): Promise { + logger.debug('TransactionLinkResolver.queryRedeemJwt... args=', { + gradidoId, + senderCommunityUuid, + senderCommunityName, + recipientCommunityUuid, + code, + amount, + memo, + firstName, + alias, + validUntil, + }) + + const redeemJwtPayloadType = new RedeemJwtPayloadType( + senderCommunityUuid, + gradidoId, + alias ?? firstName ?? '', + code, + amount, + memo, + validUntil ?? '', + ) + // TODO:encode/sign the jwt normally with the private key of the sender/home community, but interims with uuid + const homeCom = await getHomeCommunity() + if (!homeCom.communityUuid) { + throw new LogError('Home community UUID is not set') + } + const redeemJwt = await encode(redeemJwtPayloadType, homeCom.communityUuid) + // TODO: encrypt the payload with the public key of the target community + return redeemJwt + } + + @Authorized([RIGHTS.DISBURSE_TRANSACTION_LINK]) + @Mutation(() => Boolean) + async disburseTransactionLink( + @Ctx() _context: Context, + @Arg('senderCommunityUuid') senderCommunityUuid: string, + @Arg('senderGradidoId') senderGradidoId: string, + @Arg('recipientCommunityUuid') recipientCommunityUuid: string, + @Arg('recipientCommunityName') recipientCommunityName: string, + @Arg('recipientGradidoId') recipientGradidoId: string, + @Arg('recipientFirstName') recipientFirstName: string, + @Arg('code') code: string, + @Arg('amount') amount: string, + @Arg('memo') memo: string, + @Arg('validUntil', { nullable: true }) validUntil?: string, + @Arg('recipientAlias', { nullable: true }) recipientAlias?: string, + ): Promise { + logger.debug('TransactionLinkResolver.disburseTransactionLink... args=', { + senderGradidoId, + senderCommunityUuid, + recipientCommunityUuid, + recipientCommunityName, + recipientGradidoId, + recipientFirstName, + code, + amount, + memo, + validUntil, + recipientAlias, + }) + const disburseJwt = await this.createDisburseJwt( + senderCommunityUuid, + senderGradidoId, + recipientCommunityUuid, + recipientCommunityName, + recipientGradidoId, + recipientFirstName, + code, + amount, + memo, + validUntil ?? '', + recipientAlias ?? '', + ) + try { + logger.debug('TransactionLinkResolver.disburseTransactionLink... disburseJwt=', disburseJwt) + // now send the disburseJwt to the sender community to invoke a x-community-tx to disbures the redeemLink + // await sendDisburseJwtToSenderCommunity(context, disburseJwt) + } catch (e) { + throw new LogError('Disburse JWT was not sent successfully', e) + } + return true + } + @Authorized([RIGHTS.LIST_TRANSACTION_LINKS]) @Query(() => TransactionLinkResult) async listTransactionLinks( @@ -400,4 +525,163 @@ export class TransactionLinkResolver { } return transactionLinkList(paginated, filters, user) } + + async queryRedeemJwtLink(code: string): Promise { + logger.debug('TransactionLinkResolver.queryRedeemJwtLink... redeem jwt-token found') + // decode token first to get the senderCommunityUuid as input for verify token + const decodedPayload = decode(code) + logger.debug('TransactionLinkResolver.queryRedeemJwtLink... decodedPayload=', decodedPayload) + if ( + decodedPayload != null && + decodedPayload.tokentype === RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE + ) { + const redeemJwtPayload = new RedeemJwtPayloadType( + decodedPayload.sendercommunityuuid as string, + decodedPayload.sendergradidoid as string, + decodedPayload.sendername as string, + decodedPayload.redeemcode as string, + decodedPayload.amount as string, + decodedPayload.memo as string, + decodedPayload.validuntil as string, + ) + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... redeemJwtPayload=', + redeemJwtPayload, + ) + const senderCom = await getCommunityByUuid(redeemJwtPayload.sendercommunityuuid) + if (!senderCom) { + throw new LogError('Sender community not found:', redeemJwtPayload.sendercommunityuuid) + } + logger.debug('TransactionLinkResolver.queryRedeemJwtLink... senderCom=', senderCom) + if (!senderCom.communityUuid) { + throw new LogError('Sender community UUID is not set') + } + // now with the sender community UUID the jwt token can be verified + const verifiedJwtPayload = await verify(code, senderCom.communityUuid) + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... nach verify verifiedJwtPayload=', + verifiedJwtPayload, + ) + let verifiedRedeemJwtPayload: RedeemJwtPayloadType | null = null + if (verifiedJwtPayload !== null) { + if (verifiedJwtPayload.exp !== undefined) { + const expDate = new Date(verifiedJwtPayload.exp * 1000) + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... expDate, exp =', + expDate, + verifiedJwtPayload.exp, + ) + if (expDate < new Date()) { + throw new LogError('Redeem JWT-Token expired! jwtPayload.exp=', expDate) + } + } + if (verifiedJwtPayload.tokentype === RedeemJwtPayloadType.REDEEM_ACTIVATION_TYPE) { + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... verifiedJwtPayload.tokentype=', + verifiedJwtPayload.tokentype, + ) + verifiedRedeemJwtPayload = new RedeemJwtPayloadType( + verifiedJwtPayload.sendercommunityuuid as string, + verifiedJwtPayload.sendergradidoid as string, + verifiedJwtPayload.sendername as string, + verifiedJwtPayload.redeemcode as string, + verifiedJwtPayload.amount as string, + verifiedJwtPayload.memo as string, + verifiedJwtPayload.validuntil as string, + ) + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... nach verify verifiedRedeemJwtPayload=', + verifiedRedeemJwtPayload, + ) + } + } + if (verifiedRedeemJwtPayload === null) { + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... verifiedRedeemJwtPayload===null', + ) + verifiedRedeemJwtPayload = new RedeemJwtPayloadType( + decodedPayload.sendercommunityuuid as string, + decodedPayload.sendergradidoid as string, + decodedPayload.sendername as string, + decodedPayload.redeemcode as string, + decodedPayload.amount as string, + decodedPayload.memo as string, + decodedPayload.validuntil as string, + ) + } else { + // TODO: as long as the verification fails, fallback to simply decoded payload + verifiedRedeemJwtPayload = redeemJwtPayload + logger.debug( + 'TransactionLinkResolver.queryRedeemJwtLink... fallback to decode verifiedRedeemJwtPayload=', + verifiedRedeemJwtPayload, + ) + } + const homeCommunity = await getHomeCommunity() + const recipientCommunity = new Community(homeCommunity) + const senderCommunity = new Community(senderCom) + const senderUser = new User(null) + senderUser.gradidoID = verifiedRedeemJwtPayload.sendergradidoid + senderUser.firstName = verifiedRedeemJwtPayload.sendername + const redeemJwtLink = new RedeemJwtLink( + verifiedRedeemJwtPayload, + senderCommunity, + senderUser, + recipientCommunity, + ) + logger.debug('TransactionLinkResolver.queryRedeemJwtLink... redeemJwtLink=', redeemJwtLink) + return redeemJwtLink + } else { + throw new LogError( + 'Redeem with wrong type of JWT-Token or expired! decodedPayload=', + decodedPayload, + ) + } + } + + async createDisburseJwt( + senderCommunityUuid: string, + senderGradidoId: string, + recipientCommunityUuid: string, + recipientCommunityName: string, + recipientGradidoId: string, + recipientFirstName: string, + code: string, + amount: string, + memo: string, + validUntil: string, + recipientAlias: string, + ): Promise { + logger.debug('TransactionLinkResolver.createDisburseJwt... args=', { + senderCommunityUuid, + senderGradidoId, + recipientCommunityUuid, + recipientCommunityName, + recipientGradidoId, + recipientFirstName, + code, + amount, + memo, + validUntil, + recipientAlias, + }) + + const disburseJwtPayloadType = new DisburseJwtPayloadType( + senderCommunityUuid, + senderGradidoId, + recipientCommunityUuid, + recipientCommunityName, + recipientGradidoId, + recipientFirstName, + code, + amount, + memo, + validUntil, + recipientAlias, + ) + // TODO:encode/sign the jwt normally with the private key of the recipient community, but interims with uuid + const disburseJwt = await encode(disburseJwtPayloadType, recipientCommunityUuid) + logger.debug('TransactionLinkResolver.createDisburseJwt... disburseJwt=', disburseJwt) + // TODO: encrypt the payload with the public key of the target/sender community + return disburseJwt + } } diff --git a/backend/src/graphql/resolver/util/communities.ts b/backend/src/graphql/resolver/util/communities.ts index 1461f093b..31189bebc 100644 --- a/backend/src/graphql/resolver/util/communities.ts +++ b/backend/src/graphql/resolver/util/communities.ts @@ -1,5 +1,5 @@ import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database' -import { FindOneOptions } from 'typeorm' +import { FindOneOptions, IsNull, Not } from 'typeorm' import { Paginated } from '@arg/Paginated' @@ -86,6 +86,16 @@ export async function getCommunityByUuid(communityUuid: string): Promise { + const dbCommunities: DbCommunity[] = await DbCommunity.find({ + where: { communityUuid: Not(IsNull()) }, //, authenticatedAt: Not(IsNull()) }, + order: { + name: 'ASC', + }, + }) + return dbCommunities +} + export async function getCommunityByIdentifier( communityIdentifier: string, ): Promise { diff --git a/backend/src/graphql/resolver/util/syncHumhub.ts b/backend/src/graphql/resolver/util/syncHumhub.ts index 134fbe962..b483af1ce 100644 --- a/backend/src/graphql/resolver/util/syncHumhub.ts +++ b/backend/src/graphql/resolver/util/syncHumhub.ts @@ -4,7 +4,9 @@ import { HumHubClient } from '@/apis/humhub/HumHubClient' import { GetUser } from '@/apis/humhub/model/GetUser' import { PostUser } from '@/apis/humhub/model/PostUser' import { ExecutedHumhubAction, syncUser } from '@/apis/humhub/syncUser' +import { PublishNameLogic } from '@/data/PublishName.logic' import { UpdateUserInfosArgs } from '@/graphql/arg/UpdateUserInfosArgs' +import { PublishNameType } from '@/graphql/enum/PublishNameType' import { backendLogger as logger } from '@/server/logger' /** @@ -44,7 +46,9 @@ export async function syncHumhub( } const humhubUsers = new Map() if (humhubUser) { - humhubUsers.set(humhubUser.account.username, humhubUser) + const publishNameLogic = new PublishNameLogic(user) + const username = publishNameLogic.getUserIdentifier(user.humhubPublishName as PublishNameType) + humhubUsers.set(username, humhubUser) } logger.debug('update user at humhub') const result = await syncUser(user, humhubUsers) diff --git a/backend/src/graphql/union/QueryLinkResult.ts b/backend/src/graphql/union/QueryLinkResult.ts index fdf1c7b17..e506efdc6 100644 --- a/backend/src/graphql/union/QueryLinkResult.ts +++ b/backend/src/graphql/union/QueryLinkResult.ts @@ -1,9 +1,22 @@ import { createUnionType } from 'type-graphql' import { ContributionLink } from '@model/ContributionLink' +import { RedeemJwtLink } from '@model/RedeemJwtLink' import { TransactionLink } from '@model/TransactionLink' export const QueryLinkResult = createUnionType({ name: 'QueryLinkResult', // the name of the GraphQL union - types: () => [TransactionLink, ContributionLink] as const, // function that returns tuple of object types classes + types: () => [TransactionLink, RedeemJwtLink, ContributionLink] as const, // function that returns tuple of object types classes + resolveType: (value: TransactionLink | RedeemJwtLink | ContributionLink) => { + if (value instanceof TransactionLink) { + return TransactionLink + } + if (value instanceof RedeemJwtLink) { + return RedeemJwtLink + } + if (value instanceof ContributionLink) { + return ContributionLink + } + return null + }, }) diff --git a/backend/src/server/LogError.test.ts b/backend/src/server/LogError.test.ts index 88c342709..431b60e6e 100644 --- a/backend/src/server/LogError.test.ts +++ b/backend/src/server/LogError.test.ts @@ -4,13 +4,11 @@ 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' }) }) @@ -18,7 +16,6 @@ describe('LogError', () => { 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) } diff --git a/backend/src/server/createServer.ts b/backend/src/server/createServer.ts index fec8fa3b7..b87f4cb24 100644 --- a/backend/src/server/createServer.ts +++ b/backend/src/server/createServer.ts @@ -36,9 +36,11 @@ export const createServer = async ( // open mariadb connection, retry connecting with mariadb // check for correct database version - // retry max 15 times, wait 500 ms between tries - // TODO: move variables into config - const con = await checkDBVersionUntil(15, 500) + // retry max CONFIG.DB_CONNECT_RETRY_COUNT times, wait CONFIG.DB_CONNECT_RETRY_DELAY ms between tries + const con = await checkDBVersionUntil( + CONFIG.DB_CONNECT_RETRY_COUNT, + CONFIG.DB_CONNECT_RETRY_DELAY_MS, + ) // Express Server const app = express() diff --git a/backend/src/typeorm/DBVersion.ts b/backend/src/typeorm/DBVersion.ts index 6ce3ac293..f60af6d9e 100644 --- a/backend/src/typeorm/DBVersion.ts +++ b/backend/src/typeorm/DBVersion.ts @@ -6,7 +6,7 @@ import { CONFIG } from '@/config' import { Connection } from '@/typeorm/connection' import { Connection as DbConnection } from 'typeorm' -async function checkDBVersionUntil(maxRetries = 15, delayMs = 500): Promise { +async function checkDBVersionUntil(maxRetries: number, delayMs: number): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const connection = await Connection.getInstance() diff --git a/backend/test/extensions.ts b/backend/test/extensions.ts index 262a9bcdb..cf334c87c 100644 --- a/backend/test/extensions.ts +++ b/backend/test/extensions.ts @@ -1,9 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ -/* eslint-disable @typescript-eslint/no-empty-interface */ -/* eslint-disable @typescript-eslint/no-unsafe-argument */ - import { Decimal } from 'decimal.js-light' expect.extend({ @@ -28,7 +22,6 @@ interface CustomMatchers { } declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace namespace jest { interface Expect extends CustomMatchers {} interface Matchers extends CustomMatchers {} diff --git a/backend/test/helpers.ts b/backend/test/helpers.ts index 0c8d7a7a5..6d567f029 100644 --- a/backend/test/helpers.ts +++ b/backend/test/helpers.ts @@ -1,10 +1,3 @@ -/* eslint-disable @typescript-eslint/unbound-method */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ - import { createTestClient } from 'apollo-server-testing' import { entities } from 'database' diff --git a/backend/test/testSetup.ts b/backend/test/testSetup.ts index 74021f0a3..02c325794 100644 --- a/backend/test/testSetup.ts +++ b/backend/test/testSetup.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-unassigned-import import 'openai/shims/node' import { CONFIG } from '@/config' import { i18n } from '@/server/localization' diff --git a/config-schema/src/commonSchema.ts b/config-schema/src/commonSchema.ts index a208ee7dd..fa1afb473 100644 --- a/config-schema/src/commonSchema.ts +++ b/config-schema/src/commonSchema.ts @@ -37,6 +37,20 @@ export const DB_VERSION = Joi.string() ) .required() +export const DB_CONNECT_RETRY_COUNT = Joi.number() + .default(15) + .min(1) + .max(1000) + .description('Number of retries to connect to the database') + .optional() + +export const DB_CONNECT_RETRY_DELAY_MS = Joi.number() + .default(500) + .min(100) + .max(10000) + .description('Delay in milliseconds between retries to connect to the database') + .optional() + export const COMMUNITY_URL = Joi.string() .uri({ scheme: ['http', 'https'] }) .custom((value: string, helpers: Joi.CustomHelpers) => { diff --git a/database/package.json b/database/package.json index c4b9b5fca..94018860f 100644 --- a/database/package.json +++ b/database/package.json @@ -17,12 +17,12 @@ "scripts": { "build": "tsx ./esbuild.config.ts", "typecheck": "tsc --noEmit", + "lint": "biome check --error-on-warnings .", + "lint:fix": "biome check --error-on-warnings . --write", + "clear": "cross-env TZ=UTC tsx src/index.ts clear", "up": "cross-env TZ=UTC tsx src/index.ts up", "down": "cross-env TZ=UTC tsx src/index.ts down", "reset": "cross-env TZ=UTC tsx src/index.ts reset", - "clear": "cross-env TZ=UTC tsx src/index.ts clear", - "lint": "biome check --error-on-warnings .", - "lint:fix": "biome check --error-on-warnings . --write", "up:backend_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_backend tsx src/index.ts up", "up:federation_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_federation tsx src/index.ts up", "up:dht_test": "cross-env TZ=UTC DB_DATABASE=gradido_test_dht tsx src/index.ts up" diff --git a/database/src/clear.ts b/database/src/clear.ts index dfd6e51c4..e2999bbf9 100644 --- a/database/src/clear.ts +++ b/database/src/clear.ts @@ -27,7 +27,10 @@ export async function truncateTables(connection: Connection) { } export async function clearDatabase() { - const connection = await connectToDatabaseServer() + const connection = await connectToDatabaseServer( + CONFIG.DB_CONNECT_RETRY_COUNT, + CONFIG.DB_CONNECT_RETRY_DELAY_MS, + ) if (!connection) { throw new Error('Could not connect to database server') } diff --git a/database/src/config/index.ts b/database/src/config/index.ts index f66a32fa1..fdfb1b57e 100644 --- a/database/src/config/index.ts +++ b/database/src/config/index.ts @@ -11,11 +11,17 @@ const constants = { } const database = { - DB_HOST: process.env.DB_HOST || 'localhost', + DB_CONNECT_RETRY_COUNT: process.env.DB_CONNECT_RETRY_COUNT + ? Number.parseInt(process.env.DB_CONNECT_RETRY_COUNT) + : 15, + DB_CONNECT_RETRY_DELAY_MS: process.env.DB_CONNECT_RETRY_DELAY_MS + ? Number.parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS) + : 500, + DB_HOST: process.env.DB_HOST ?? 'localhost', DB_PORT: process.env.DB_PORT ? Number.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', + DB_USER: process.env.DB_USER ?? 'root', + DB_PASSWORD: process.env.DB_PASSWORD ?? '', + DB_DATABASE: process.env.DB_DATABASE ?? 'gradido_community', } const migrations = { diff --git a/database/src/prepare.ts b/database/src/prepare.ts index fc359c226..e29fc1422 100644 --- a/database/src/prepare.ts +++ b/database/src/prepare.ts @@ -11,8 +11,8 @@ export enum DatabaseState { } export async function connectToDatabaseServer( - maxRetries = 15, - delayMs = 500, + maxRetries: number, + delayMs: number, ): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { @@ -42,7 +42,10 @@ async function convertJsToTsInMigrations(connection: Connection): Promise => { - const connection = await connectToDatabaseServer() + const connection = await connectToDatabaseServer( + CONFIG.DB_CONNECT_RETRY_COUNT, + CONFIG.DB_CONNECT_RETRY_DELAY_MS, + ) if (!connection) { return DatabaseState.NOT_CONNECTED } diff --git a/database/turbo.json b/database/turbo.json index ef38daaeb..8339cc11f 100644 --- a/database/turbo.json +++ b/database/turbo.json @@ -14,13 +14,16 @@ "cache": false }, "up": { - "cache": false + "cache": false, + "dependsOn": ["build"] }, "down": { - "cache": false + "cache": false, + "dependsOn": ["build"] }, "reset": { - "cache": false + "cache": false, + "dependsOn": ["build"] } } } \ No newline at end of file diff --git a/deployment/bare_metal/start.sh b/deployment/bare_metal/start.sh index d75bd4eb6..d8acd65e4 100755 --- a/deployment/bare_metal/start.sh +++ b/deployment/bare_metal/start.sh @@ -22,18 +22,11 @@ done # set $1, $2, ... only with position arguments set -- "${POSITIONAL_ARGS[@]}" - -# check for missing branch name -if [ -z "$1" ]; then - echo "Usage: $0 [--fast] " - exit 1 -fi - BRANCH_NAME="$1" -# Debug-Ausgabe -if [ -z "$1" ]; then - echo "Usage: Please provide a branch name as the first argument." +# check for parameter +if [ -z "$BRANCH_NAME" ]; then + echo "Usage: $0 [--fast] [--fast]" exit 1 fi echo "Use branch: $BRANCH_NAME" @@ -51,9 +44,17 @@ PROJECT_ROOT=$SCRIPT_DIR/../.. NGINX_CONFIG_DIR=$SCRIPT_DIR/nginx/sites-available set +o allexport -# enable nvm +# enable nvm export NVM_DIR="$HOME/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" -nvm use +install_nvm() { + nvm install + nvm use + nvm alias default + npm i -g yarn pm2 + pm2 startup +} +# make sure correct node version is installed +nvm use || install_nvm # NOTE: all config values will be in process.env when starting # the services and will therefore take precedence over the .env @@ -170,14 +171,12 @@ else log_warn "PM2 is already empty" fi - # git -BRANCH=$1 -log_step "Starting with git pull - branch:$BRANCH" +log_step "Starting with git pull - branch:$BRANCH_NAME" cd $PROJECT_ROOT # TODO: this overfetches alot, but ensures we can use start.sh with tags git fetch --all -git checkout $BRANCH +git checkout $BRANCH_NAME git pull export BUILD_COMMIT="$(git rev-parse HEAD)" @@ -289,7 +288,6 @@ else turbo up --env-mode=loose fi -nvm use default # start after building all to use up less ressources pm2 start --name gradido-backend "turbo backend#start --env-mode=loose" -l $GRADIDO_LOG_PATH/pm2.backend.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' #pm2 start --name gradido-frontend "yarn --cwd $PROJECT_ROOT/frontend start" -l $GRADIDO_LOG_PATH/pm2.frontend.$TODAY.log --log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' @@ -335,4 +333,4 @@ cat $UPDATE_HTML >> $GRADIDO_LOG_PATH/update.$TODAY.log log_success " /\\_/\\ " log_success "( ^.^ ) Update finished successfully!" -log_success " > <" \ No newline at end of file +log_success " > <" diff --git a/dht-node/jest.config.js b/dht-node/jest.config.js index b0842b949..18170ac48 100644 --- a/dht-node/jest.config.js +++ b/dht-node/jest.config.js @@ -1,5 +1,4 @@ /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ -// eslint-disable-next-line import/no-commonjs, import/unambiguous module.exports = { verbose: true, preset: 'ts-jest', diff --git a/dht-node/src/config/index.ts b/dht-node/src/config/index.ts index 60a6d3b90..14b4a789b 100644 --- a/dht-node/src/config/index.ts +++ b/dht-node/src/config/index.ts @@ -1,4 +1,3 @@ -/* eslint-disable n/no-process-env */ import { validate } from 'config-schema' import { latestDbVersion } from 'database' import dotenv from 'dotenv' @@ -19,6 +18,12 @@ const server = { } const database = { + DB_CONNECT_RETRY_COUNT: process.env.DB_CONNECT_RETRY_COUNT + ? Number.parseInt(process.env.DB_CONNECT_RETRY_COUNT) + : 15, + DB_CONNECT_RETRY_DELAY_MS: process.env.DB_CONNECT_RETRY_DELAY_MS + ? Number.parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS) + : 500, DB_HOST: process.env.DB_HOST ?? 'localhost', DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306, DB_USER: process.env.DB_USER ?? 'root', diff --git a/dht-node/src/config/schema.ts b/dht-node/src/config/schema.ts index 659720c06..67dac73a4 100644 --- a/dht-node/src/config/schema.ts +++ b/dht-node/src/config/schema.ts @@ -1,6 +1,8 @@ import { COMMUNITY_DESCRIPTION, COMMUNITY_NAME, + DB_CONNECT_RETRY_COUNT, + DB_CONNECT_RETRY_DELAY_MS, DB_DATABASE, DB_HOST, DB_PASSWORD, @@ -19,6 +21,8 @@ export const schema = Joi.object({ COMMUNITY_NAME, COMMUNITY_DESCRIPTION, DB_DATABASE, + DB_CONNECT_RETRY_COUNT, + DB_CONNECT_RETRY_DELAY_MS, DB_HOST, DB_PASSWORD, DB_PORT, diff --git a/dht-node/src/dht_node/index.test.ts b/dht-node/src/dht_node/index.test.ts index 381a17f74..5ffc3cb5f 100644 --- a/dht-node/src/dht_node/index.test.ts +++ b/dht-node/src/dht_node/index.test.ts @@ -1,6 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ - import DHT from '@hyperswarm/dht' import { Community as DbCommunity, FederatedCommunity as DbFederatedCommunity } from 'database' import { validate as validateUUID, version as versionUUID } from 'uuid' diff --git a/dht-node/src/dht_node/index.ts b/dht-node/src/dht_node/index.ts index fe13353e0..69cf86681 100644 --- a/dht-node/src/dht_node/index.ts +++ b/dht-node/src/dht_node/index.ts @@ -1,5 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -/* eslint-disable @typescript-eslint/explicit-module-boundary-types */ import DHT from '@hyperswarm/dht' import { CommunityLoggingView, diff --git a/dht-node/src/index.ts b/dht-node/src/index.ts index 901d3ab20..e7058f152 100644 --- a/dht-node/src/index.ts +++ b/dht-node/src/index.ts @@ -6,7 +6,7 @@ import { checkDBVersionUntil } from './typeorm/DBVersion' async function main() { // open mysql connection - await checkDBVersionUntil() + await checkDBVersionUntil(CONFIG.DB_CONNECT_RETRY_COUNT, CONFIG.DB_CONNECT_RETRY_DELAY_MS) logger.debug(`dhtseed set by CONFIG.FEDERATION_DHT_SEED=${CONFIG.FEDERATION_DHT_SEED}`) logger.info( `starting Federation on ${CONFIG.FEDERATION_DHT_TOPIC} ${ diff --git a/dht-node/src/typeorm/DBVersion.ts b/dht-node/src/typeorm/DBVersion.ts index ee97c3c0a..be9f0c612 100644 --- a/dht-node/src/typeorm/DBVersion.ts +++ b/dht-node/src/typeorm/DBVersion.ts @@ -6,7 +6,7 @@ import { CONFIG } from '@/config' import { Connection as DbConnection } from 'typeorm' import { connection as connectionFunc } from './connection' -async function checkDBVersionUntil(maxRetries = 15, delayMs = 500): Promise { +async function checkDBVersionUntil(maxRetries: number, delayMs: number): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const connection = await connectionFunc() diff --git a/dht-node/test/helpers.ts b/dht-node/test/helpers.ts index 0301f7fae..1fd42066a 100644 --- a/dht-node/test/helpers.ts +++ b/dht-node/test/helpers.ts @@ -1,6 +1,7 @@ import { entities } from 'database' import { checkDBVersionUntil } from '@/typeorm/DBVersion' +import { CONFIG } from '@/config' export const headerPushMock = jest.fn((t) => { context.token = t.value @@ -23,7 +24,7 @@ export const cleanDB = async () => { } export const testEnvironment = async () => { - return { con: await checkDBVersionUntil() } + return { con: await checkDBVersionUntil(CONFIG.DB_CONNECT_RETRY_COUNT, CONFIG.DB_CONNECT_RETRY_DELAY_MS) } } export const resetEntity = async (entity: any) => { diff --git a/docu/Concepts/TechnicalRequirements/image/redeemlink_without_community-switch.png b/docu/Concepts/TechnicalRequirements/image/redeemlink_without_community-switch.png new file mode 100644 index 000000000..a929d5ff7 Binary files /dev/null and b/docu/Concepts/TechnicalRequirements/image/redeemlink_without_community-switch.png differ diff --git a/federation/jest.config.js b/federation/jest.config.js index 17386f225..44ddf9bf5 100644 --- a/federation/jest.config.js +++ b/federation/jest.config.js @@ -25,7 +25,6 @@ module.exports = { ? '/../database/entity/$1' : '/../database/build/entity/$1', '@logging/(.*)': - // eslint-disable-next-line n/no-process-env process.env.NODE_ENV === 'development' ? '/../database/logging/$1' : '/../database/build/logging/$1', @@ -34,7 +33,6 @@ module.exports = { ? '/../database/src/$1' : '/../database/build/src/$1', '@config/(.*)': - // eslint-disable-next-line n/no-process-env process.env.NODE_ENV === 'development' ? '/../config/src/$1' : '/../config/build/$1', diff --git a/federation/src/config/index.ts b/federation/src/config/index.ts index 4c5f162ff..3c759d702 100644 --- a/federation/src/config/index.ts +++ b/federation/src/config/index.ts @@ -30,6 +30,12 @@ const server = { PRODUCTION: process.env.NODE_ENV === 'production', } const database = { + DB_CONNECT_RETRY_COUNT: process.env.DB_CONNECT_RETRY_COUNT + ? Number.parseInt(process.env.DB_CONNECT_RETRY_COUNT) + : 15, + DB_CONNECT_RETRY_DELAY_MS: process.env.DB_CONNECT_RETRY_DELAY_MS + ? Number.parseInt(process.env.DB_CONNECT_RETRY_DELAY_MS) + : 500, DB_HOST: process.env.DB_HOST ?? 'localhost', DB_PORT: process.env.DB_PORT ? Number.parseInt(process.env.DB_PORT) : 3306, DB_USER: process.env.DB_USER ?? 'root', diff --git a/federation/src/config/schema.ts b/federation/src/config/schema.ts index 2926061eb..812728cff 100644 --- a/federation/src/config/schema.ts +++ b/federation/src/config/schema.ts @@ -1,4 +1,6 @@ import { + DB_CONNECT_RETRY_COUNT, + DB_CONNECT_RETRY_DELAY_MS, DB_DATABASE, DB_HOST, DB_PASSWORD, @@ -17,6 +19,8 @@ import Joi from 'joi' export const schema = Joi.object({ DB_DATABASE, + DB_CONNECT_RETRY_COUNT, + DB_CONNECT_RETRY_DELAY_MS, DB_HOST, DB_PASSWORD, DB_PORT, diff --git a/federation/src/server/createServer.ts b/federation/src/server/createServer.ts index d3df01e18..e737d2a61 100644 --- a/federation/src/server/createServer.ts +++ b/federation/src/server/createServer.ts @@ -17,6 +17,7 @@ import { schema } from '@/graphql/schema' // import { elopageWebhook } from '@/webhook/elopage' import { Connection } from 'typeorm' +import { CONFIG } from '@/config' import { slowDown } from 'express-slow-down' import helmet from 'helmet' import { Logger } from 'log4js' @@ -39,7 +40,10 @@ export const createServer = async ( logger.debug('createServer...') // open mysql connection - const con = await checkDBVersionUntil() + const con = await checkDBVersionUntil( + CONFIG.DB_CONNECT_RETRY_COUNT, + CONFIG.DB_CONNECT_RETRY_DELAY_MS, + ) // Express Server const app = express() diff --git a/federation/src/typeorm/DBVersion.ts b/federation/src/typeorm/DBVersion.ts index ed907d2d8..712c4ee4f 100644 --- a/federation/src/typeorm/DBVersion.ts +++ b/federation/src/typeorm/DBVersion.ts @@ -4,7 +4,7 @@ import { Migration } from 'database' import { Connection as DbConnection } from 'typeorm' import { connection as connectionFunc } from './connection' -async function checkDBVersionUntil(maxRetries = 15, delayMs = 500): Promise { +async function checkDBVersionUntil(maxRetries: number, delayMs: number): Promise { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const connection = await connectionFunc() diff --git a/frontend/src/components/CommunitySwitch.vue b/frontend/src/components/CommunitySwitch.vue index cd50789dd..8775e8667 100644 --- a/frontend/src/components/CommunitySwitch.vue +++ b/frontend/src/components/CommunitySwitch.vue @@ -46,6 +46,7 @@ const validCommunityIdentifier = ref(false) const { onResult } = useQuery(selectCommunities) onResult(({ data }) => { + // console.log('CommunitySwitch.onResult...data=', data) if (data) { communities.value = data.communities setDefaultCommunity() @@ -55,22 +56,42 @@ onResult(({ data }) => { const communityIdentifier = computed(() => route.params.communityIdentifier) function updateCommunity(community) { + // console.log('CommunitySwitch.updateCommunity...community=', community) emit('update:model-value', community) } function setDefaultCommunity() { + // console.log( + // 'CommunitySwitch.setDefaultCommunity... communityIdentifier= communities=', + // communityIdentifier, + // communities, + // ) if (communityIdentifier.value && communities.value.length >= 1) { + // console.log( + // 'CommunitySwitch.setDefaultCommunity... communities.value.length=', + // communities.value.length, + // ) const foundCommunity = communities.value.find((community) => { + // console.log('CommunitySwitch.setDefaultCommunity... community=', community) if ( community.uuid === communityIdentifier.value || community.name === communityIdentifier.value ) { validCommunityIdentifier.value = true + // console.log( + // 'CommunitySwitch.setDefaultCommunity...true validCommunityIdentifier=', + // validCommunityIdentifier, + // ) return true } + // console.log( + // 'CommunitySwitch.setDefaultCommunity...false validCommunityIdentifier=', + // validCommunityIdentifier, + // ) return false }) if (foundCommunity) { + // console.log('CommunitySwitch.setDefaultCommunity...foundCommunity=', foundCommunity) updateCommunity(foundCommunity) return } @@ -79,10 +100,20 @@ function setDefaultCommunity() { if (validCommunityIdentifier.value && !communityIdentifier.value) { validCommunityIdentifier.value = false + // console.log( + // 'CommunitySwitch.setDefaultCommunity...validCommunityIdentifier=', + // validCommunityIdentifier, + // ) } if (props.modelValue?.uuid === '' && communities.value.length) { + // console.log( + // 'CommunitySwitch.setDefaultCommunity...props.modelValue= communities=', + // props.modelValue, + // communities.value.length, + // ) const foundCommunity = communities.value.find((community) => !community.foreign) + // console.log('CommunitySwitch.setDefaultCommunity...foundCommunity=', foundCommunity) if (foundCommunity) { updateCommunity(foundCommunity) } diff --git a/frontend/src/components/LinkInformations/RedeemCommunitySelection.vue b/frontend/src/components/LinkInformations/RedeemCommunitySelection.vue new file mode 100644 index 000000000..46e9eb81f --- /dev/null +++ b/frontend/src/components/LinkInformations/RedeemCommunitySelection.vue @@ -0,0 +1,182 @@ + + diff --git a/frontend/src/components/LinkInformations/RedeemInformation.vue b/frontend/src/components/LinkInformations/RedeemInformation.vue index 6793240e2..c89c7c3e9 100644 --- a/frontend/src/components/LinkInformations/RedeemInformation.vue +++ b/frontend/src/components/LinkInformations/RedeemInformation.vue @@ -1,16 +1,20 @@ @@ -20,10 +24,9 @@ import CONFIG from '@/config' export default { name: 'RedeemInformation', props: { - user: { type: Object, required: false }, - amount: { type: String, required: true }, - memo: { type: String, required: true, default: '' }, + linkData: { type: Object, required: true }, isContributionLink: { type: Boolean, default: false }, + isRedeemJwtLink: { type: Boolean, default: false }, }, data() { return { diff --git a/frontend/src/components/LinkInformations/RedeemSelectCommunity.vue b/frontend/src/components/LinkInformations/RedeemSelectCommunity.vue new file mode 100644 index 000000000..f93e25a04 --- /dev/null +++ b/frontend/src/components/LinkInformations/RedeemSelectCommunity.vue @@ -0,0 +1,59 @@ + + + diff --git a/frontend/src/components/LinkInformations/RedeemValid.vue b/frontend/src/components/LinkInformations/RedeemValid.vue index d97de8b5f..aaf52d313 100644 --- a/frontend/src/components/LinkInformations/RedeemValid.vue +++ b/frontend/src/components/LinkInformations/RedeemValid.vue @@ -1,6 +1,11 @@