diff --git a/.eslintrc.js b/.eslintrc.js index 703cede..1aba513 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,7 @@ module.exports = { jest: true, }, rules: { + '@typescript-eslint/no-unsafe-call': 'warn', '@typescript-eslint/no-unsafe-argument': 'warn', '@typescript-eslint/no-unsafe-assignment': 'warn', '@typescript-eslint/no-unsafe-member-access': 'warn', diff --git a/CHANGELOG.md b/CHANGELOG.md index e11c2b9..75f9e58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - forms now have multiple notification - layout for forms - mariadb / mysql support +- user confirmation tokens +- email verification ### Changed @@ -20,6 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - colors object removed the "colors" postfix - if unsupported database engine is used error is thrown during startup - improved eslint checks +- validate submission field data and store it json encoded ### Fixed diff --git a/doc/environment.md b/doc/environment.md index ad96369..43ac122 100644 --- a/doc/environment.md +++ b/doc/environment.md @@ -1,43 +1,46 @@ # Environment Variables -| Name | Default Value | Description | -| ---- | ------------- | ----------- | -| DISABLE_INSTALLATION_METRICS | *not set* | Per default installations are [publishing](./installation.metrics.md) their existence | -| SECRET_KEY | `changeMe` | JWT Secret for authentication | -| CLI | *automatically* | activates pretty print for log output | -| NODE_ENV | `production` | | -| HIDE_CONTRIB | `false` | decide if backlings to ohmyform should be added | -| SIGNUP_DISABLED | `false` | if users can sign up | -| LOGIN_NOTE | *not set* | Info box on top of login screen | -| LOCALES_PATH | *not set* | Path to translated elementes in backend like emails | +| Name | Default Value | Description | +|------------------------------|----------------------------|---------------------------------------------------------------------------------------| +| DISABLE_INSTALLATION_METRICS | *not set* | Per default installations are [publishing](./installation.metrics.md) their existence | +| SECRET_KEY | `changeMe` | JWT Secret for authentication | +| CLI | *automatically* | activates pretty print for log output | +| NODE_ENV | `production` | | +| HIDE_CONTRIB | `false` | decide if backlings to ohmyform should be added | +| SIGNUP_DISABLED | `false` | if users can sign up | +| LOGIN_NOTE | *not set* | Info box on top of login screen | +| LOCALES_PATH | *not set* | Path to translated elementes in backend like emails | +| LOCALE | `en` | Default Locale | +| BASE_URL | `http://localhost` | Url to Frontend root | +| USER_CONFIRM_PATH | `/confirm?token={{token}}` | Path to confirm user | ## Default Account *username and email are unique on an instance* -| Name | Default Value | Description | -| ---- | ------------- | ----------- | -| CREATE_ADMIN | `false` | if `true` will create a super admin | -| ADMIN_USERNAME | `root` | username for the default admin user | -| ADMIN_EMAIL | `admin@ohmyform.com` | email to send notifications | -| ADMIN_PASSWORD | `root` | password for user | +| Name | Default Value | Description | +|----------------|----------------------|-------------------------------------| +| CREATE_ADMIN | `false` | if `true` will create a super admin | +| ADMIN_USERNAME | `root` | username for the default admin user | +| ADMIN_EMAIL | `admin@ohmyform.com` | email to send notifications | +| ADMIN_PASSWORD | `root` | password for user | ## Mailing -| Name | Default Value | Description | -| ---- | ------------- | ----------- | -| MAILER_URI | `smtp://localhost:1025` | [Mail Connection](https://nodemailer.com/smtp/) | +| Name | Default Value | Description | +|-------------|---------------------------------|-----------------------------------------------------------------------------------| +| MAILER_URI | `smtp://localhost:1025` | [Mail Connection](https://nodemailer.com/smtp/) | | MAILER_FROM | `OhMyForm ` | Default From path, make sure that your mail server supports the given from addres | ## Database Variables -| Name | Default Value | Description | -| ---- | ------------- | ----------- | -| DATABASE_DRIVER | `sqlite` | database driver, either `sqlite` or `postgres` | -| DATABASE_URL | `sqlite://data.sqlite` | url in the format `TYPE://USER:PASS@HOST:PORT/NAME?EXTRA` ([read more](https://typeorm.io/#/connection-options/common-connection-options)) | -| DATABASE_TABLE_PREFIX | *empty* | prefix all tables if used within same database as other applications. | -| DATABASE_LOGGING | `false` | if `true` all db interactions will be logged to stdout | -| DATABASE_MIGRATE | `true` | can be used in load balanced environments to only allow one container to perform migrations / manually execute migrations -| DATABASE_SSL | `false` | if `true` will require ssl database connection | -| REDIS_HOST | *not set* | required in multinode environments | -| REDIS_PORT | `6379` | port for redis | +| Name | Default Value | Description | +|-----------------------|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------| +| DATABASE_DRIVER | `sqlite` | database driver, either `sqlite` or `postgres` | +| DATABASE_URL | `sqlite://data.sqlite` | url in the format `TYPE://USER:PASS@HOST:PORT/NAME?EXTRA` ([read more](https://typeorm.io/#/connection-options/common-connection-options)) | +| DATABASE_TABLE_PREFIX | *empty* | prefix all tables if used within same database as other applications. | +| DATABASE_LOGGING | `false` | if `true` all db interactions will be logged to stdout | +| DATABASE_MIGRATE | `true` | can be used in load balanced environments to only allow one container to perform migrations / manually execute migrations | +| DATABASE_SSL | `false` | if `true` will require ssl database connection | +| REDIS_HOST | *not set* | required in multinode environments | +| REDIS_PORT | `6379` | port for redis | diff --git a/ormconfig_maria.json b/ormconfig_mariadb.json similarity index 71% rename from ormconfig_maria.json rename to ormconfig_mariadb.json index 9f87581..b504f53 100644 --- a/ormconfig_maria.json +++ b/ormconfig_mariadb.json @@ -10,12 +10,13 @@ "src/entity/**/*.ts" ], "migrations": [ - "src/migrations/maria/**/*.ts" + "src/migrations/mariadb/**/*.ts" ], + "migrationsTransactionMode": "each", "subscribers": [ "src/subscriber/**/*.ts" ], "cli": { - "migrationsDir": "src/migrations/maria" + "migrationsDir": "src/migrations/mariadb" } } diff --git a/ormconfig_postgres.json b/ormconfig_postgres.json index 0b19895..373c254 100644 --- a/ormconfig_postgres.json +++ b/ormconfig_postgres.json @@ -12,6 +12,7 @@ "migrations": [ "src/migrations/postgres/**/*.ts" ], + "migrationsTransactionMode": "each", "subscribers": [ "src/subscriber/**/*.ts" ], diff --git a/ormconfig_sqlite.json b/ormconfig_sqlite.json index 04b538e..fbc3c47 100644 --- a/ormconfig_sqlite.json +++ b/ormconfig_sqlite.json @@ -9,6 +9,7 @@ "migrations": [ "src/migrations/sqlite/**/*.ts" ], + "migrationsTransactionMode": "each", "cli": { "migrationsDir": "src/migrations/sqlite" } diff --git a/package.json b/package.json index 5e8cf33..d2d3a9e 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json", "typeorm:sqlite": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_sqlite.json", "typeorm:postgres": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_postgres.json", - "typeorm:maria": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_maria.json" + "typeorm:mariadb": "cross-env TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -f ormconfig_mariadb.json" }, "dependencies": { "@nestjs-modules/mailer": "^1.6.0", @@ -82,6 +82,7 @@ "@types/html-to-text": "^8.0.1", "@types/inquirer": "^8.1.3", "@types/jest": "26.0.23", + "@types/mjml": "^4.7.0", "@types/node": "^16.11.17", "@types/passport-jwt": "^3.0.6", "@types/passport-local": "^1.0.34", diff --git a/src/app.imports.ts b/src/app.imports.ts index c05c26c..e127cc7 100644 --- a/src/app.imports.ts +++ b/src/app.imports.ts @@ -49,7 +49,7 @@ export const imports = [ }), ServeStaticModule.forRoot({ rootPath: join(__dirname, '..', 'public'), - exclude: [], + exclude: ['/graphql'], }), ConfigModule.forRoot({ load: [ @@ -122,8 +122,8 @@ export const imports = [ break case 'mysql': - case 'maria': - migrationFolder = 'maria' + case 'mariadb': + migrationFolder = 'mariadb' break case 'sqlite': @@ -146,6 +146,7 @@ export const imports = [ entities, migrations: [`${__dirname}/**/migrations/${migrationFolder}/**/*{.ts,.js}`], migrationsRun: configService.get('DATABASE_MIGRATE', true), + migrationsTransactionMode: 'each', }) }, }), diff --git a/src/dto/form/form.field.logic.input.ts b/src/dto/form/form.field.logic.input.ts index b3f844e..b1553c2 100644 --- a/src/dto/form/form.field.logic.input.ts +++ b/src/dto/form/form.field.logic.input.ts @@ -1,4 +1,5 @@ import { Field, ID, InputType } from '@nestjs/graphql' +import { FormFieldLogicAction } from '../../entity/form.field.logic.entity' @InputType() export class FormFieldLogicInput { @@ -8,8 +9,9 @@ export class FormFieldLogicInput { @Field({ nullable: true }) readonly formula: string - @Field({ nullable: true }) - readonly action: string + // TODO verify action value + @Field(() => String, { nullable: true }) + readonly action: FormFieldLogicAction @Field(() => ID, { nullable: true }) readonly jumpTo?: string diff --git a/src/dto/submission/submission.field.model.ts b/src/dto/submission/submission.field.model.ts index 5d44958..e13a63d 100644 --- a/src/dto/submission/submission.field.model.ts +++ b/src/dto/submission/submission.field.model.ts @@ -14,7 +14,7 @@ export class SubmissionFieldModel { constructor(field: SubmissionFieldEntity) { this.id = field.id.toString() - this.value = field.fieldValue - this.type = field.fieldType + this.value = JSON.stringify(field.content) + this.type = field.type } } diff --git a/src/dto/user/user.model.ts b/src/dto/user/user.model.ts index cb2b048..e790292 100644 --- a/src/dto/user/user.model.ts +++ b/src/dto/user/user.model.ts @@ -6,9 +6,15 @@ export class UserModel { @Field(() => ID) readonly id: string - @Field() + /** + * @deprecated use emailVerified instead + */ + @Field({ deprecationReason: 'use emailVerified instead' }) readonly verifiedEmail: boolean + @Field() + readonly emailVerified: boolean + @Field() readonly username: string @@ -39,7 +45,8 @@ export class UserModel { this.firstName = user.firstName this.lastName = user.lastName - this.verifiedEmail = !user.token + this.verifiedEmail = user.emailVerified + this.emailVerified = user.emailVerified this.created = user.created this.lastModified = user.lastModified diff --git a/src/entity/form.field.logic.entity.ts b/src/entity/form.field.logic.entity.ts index 1737a11..9ddc22a 100644 --- a/src/entity/form.field.logic.entity.ts +++ b/src/entity/form.field.logic.entity.ts @@ -1,7 +1,7 @@ import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm' import { FormFieldEntity } from './form.field.entity' -type LogicAction = 'visible' | 'require' | 'disable' | 'jumpTo' +export type FormFieldLogicAction = 'visible' | 'require' | 'disable' | 'jumpTo' @Entity({ name: 'form_field_logic' }) export class FormFieldLogicEntity { @@ -15,7 +15,7 @@ export class FormFieldLogicEntity { public formula: string @Column({ type: 'varchar', length: 10 }) - public action: LogicAction + public action: FormFieldLogicAction @Column({ nullable: true }) public visible?: boolean diff --git a/src/entity/submission.field.entity.ts b/src/entity/submission.field.entity.ts index f138c6d..e3921e0 100644 --- a/src/entity/submission.field.entity.ts +++ b/src/entity/submission.field.entity.ts @@ -2,6 +2,10 @@ import { Column, Entity, ManyToOne, PrimaryGeneratedColumn, RelationId } from 't import { FormFieldEntity } from './form.field.entity' import { SubmissionEntity } from './submission.entity' +export interface SubmissionFieldContent { + [key: string]: string | string[] | number | number[] | boolean | boolean[] +} + @Entity({ name: 'submission_field' }) export class SubmissionFieldEntity { @PrimaryGeneratedColumn() @@ -17,8 +21,8 @@ export class SubmissionFieldEntity { readonly fieldId: number @Column() - public fieldType: string + public type: string - @Column() - public fieldValue: string + @Column('simple-json') + public content: SubmissionFieldContent } diff --git a/src/entity/user.entity.ts b/src/entity/user.entity.ts index 202fe1f..5cb58a4 100644 --- a/src/entity/user.entity.ts +++ b/src/entity/user.entity.ts @@ -15,6 +15,9 @@ export class UserEntity { @Column({ length: 255, unique: true }) public email: string + @Column('boolean', { default: false }) + public emailVerified = false + @Column({ length: 255, unique: true }) public username: string diff --git a/src/main.ts b/src/main.ts index 489c456..0f88766 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,11 @@ import { NestApplicationOptions, ValidationPipe } from '@nestjs/common' import { NestFactory } from '@nestjs/core' import cors from 'cors' -import { Logger, PinoLogger } from 'nestjs-pino' -import { LoggerConfig } from './app.imports' +import { Logger } from 'nestjs-pino' import { AppModule } from './app.module' (async () => { const options: NestApplicationOptions = { - logger: new Logger(new PinoLogger(LoggerConfig), {}), bufferLogs: true, } diff --git a/src/migrations/maria/1619723437787-initial.ts b/src/migrations/mariadb/1619723437787-initial.ts similarity index 100% rename from src/migrations/maria/1619723437787-initial.ts rename to src/migrations/mariadb/1619723437787-initial.ts diff --git a/src/migrations/maria/1621078163528-layout.ts b/src/migrations/mariadb/1621078163528-layout.ts similarity index 100% rename from src/migrations/maria/1621078163528-layout.ts rename to src/migrations/mariadb/1621078163528-layout.ts diff --git a/src/migrations/mariadb/1641124349039-submission.ts b/src/migrations/mariadb/1641124349039-submission.ts new file mode 100644 index 0000000..41cb15e --- /dev/null +++ b/src/migrations/mariadb/1641124349039-submission.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class submission1641124349039 implements MigrationInterface { + name = 'submission1641124349039' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `submission_field` RENAME COLUMN `fieldType` TO `type`'); + await queryRunner.query('ALTER TABLE `submission_field` RENAME COLUMN `fieldValue` TO `content`'); + await queryRunner.query('ALTER TABLE `submission_field` MODIFY COLUMN `content` text NOT NULL'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `submission_field` MODIFY COLUMN `content` varchar(255) NOT NULL'); + await queryRunner.query('ALTER TABLE `submission_field` RENAME COLUMN `content` TO `fieldValue`'); + await queryRunner.query('ALTER TABLE `submission_field` RENAME COLUMN `type` TO `fieldType`'); + } +} diff --git a/src/migrations/mariadb/1641132645227-confirm.ts b/src/migrations/mariadb/1641132645227-confirm.ts new file mode 100644 index 0000000..ec0215d --- /dev/null +++ b/src/migrations/mariadb/1641132645227-confirm.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class confirm1641132645227 implements MigrationInterface { + name = 'confirm1641132645227' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user` ADD `emailVerified` tinyint NOT NULL DEFAULT 0'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `user` DROP COLUMN `emailVerified`'); + } +} diff --git a/src/migrations/postgres/1641124349039-submission.ts b/src/migrations/postgres/1641124349039-submission.ts new file mode 100644 index 0000000..2929765 --- /dev/null +++ b/src/migrations/postgres/1641124349039-submission.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class submission1641124349039 implements MigrationInterface { + name = 'submission1641124349039' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "submission_field" RENAME COLUMN "fieldType" TO "type"'); + await queryRunner.query('ALTER TABLE "submission_field" RENAME COLUMN "fieldValue" TO "content"'); + await queryRunner.query('ALTER TABLE "submission_field" ALTER COLUMN "content" type text'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "submission_field" ALTER COLUMN "content" type character varying'); + await queryRunner.query('ALTER TABLE "submission_field" RENAME COLUMN "content" TO "fieldValue"'); + await queryRunner.query('ALTER TABLE "submission_field" RENAME COLUMN "type" TO "fieldType"'); + } +} diff --git a/src/migrations/postgres/1641132645227-confirm.ts b/src/migrations/postgres/1641132645227-confirm.ts new file mode 100644 index 0000000..f13f0f3 --- /dev/null +++ b/src/migrations/postgres/1641132645227-confirm.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class confirm1641132645227 implements MigrationInterface { + name = 'confirm1641132645227' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "user" ADD "emailVerified" boolean NOT NULL DEFAULT false'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "user" DROP COLUMN "emailVerified"'); + } +} diff --git a/src/migrations/sqlite/1619723437787-initial.ts b/src/migrations/sqlite/1619723437787-initial.ts index d6de801..44225aa 100644 --- a/src/migrations/sqlite/1619723437787-initial.ts +++ b/src/migrations/sqlite/1619723437787-initial.ts @@ -15,6 +15,10 @@ export class initial1619723437787 implements MigrationInterface { await queryRunner.query('CREATE TABLE "submission_field" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "fieldType" varchar NOT NULL, "fieldValue" varchar NOT NULL, "submissionId" integer, "fieldId" integer, CONSTRAINT "FK_16fae661ce5b10f27abe2e524a0" FOREIGN KEY ("submissionId") REFERENCES "submission" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_5befa92da2370b7eb1cab6ae30a" FOREIGN KEY ("fieldId") REFERENCES "form_field" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); await queryRunner.query('CREATE TABLE "form_visitor" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "referrer" varchar, "ipAddr" varchar NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "updated" datetime NOT NULL DEFAULT (datetime(\'now\')), "formId" integer, "geoLocationCountry" varchar, "geoLocationCity" varchar, "deviceLanguage" varchar, "deviceType" varchar, "deviceName" varchar, CONSTRAINT "FK_72ade6c3a3e55d1fce94300f8b6" FOREIGN KEY ("formId") REFERENCES "form" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); await queryRunner.query('CREATE TABLE "submission" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "ipAddr" varchar NOT NULL, "tokenHash" varchar NOT NULL, "timeElapsed" numeric NOT NULL, "percentageComplete" numeric NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "formId" integer, "visitorId" integer, "userId" integer, "geoLocationCountry" varchar, "geoLocationCity" varchar, "deviceLanguage" varchar, "deviceType" varchar, "deviceName" varchar, CONSTRAINT "FK_6090e1d5cbf3433ffd14e3b53e7" FOREIGN KEY ("formId") REFERENCES "form" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_95b73c7faf2c199f005fda5e8c8" FOREIGN KEY ("visitorId") REFERENCES "form_visitor" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_7bd626272858ef6464aa2579094" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); + + + + await queryRunner.query('CREATE TABLE "form" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "language" varchar(10) NOT NULL, "showFooter" boolean NOT NULL, "isLive" boolean NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "adminId" integer, "startPageId" integer, "endPageId" integer, "analyticsGacode" varchar, "designFont" varchar, "designColorsBackground" varchar, "designColorsQuestion" varchar, "designColorsAnswer" varchar, "designColorsButton" varchar, "designColorsButtonactive" varchar, "designColorsButtontext" varchar, CONSTRAINT "FK_a7cb33580bca2b362e5e34fdfcd" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_023d9cf1d97e93facc96c86ca70" FOREIGN KEY ("startPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_e5d158932e43cfbf9958931ee01" FOREIGN KEY ("endPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); } diff --git a/src/migrations/sqlite/1621078163528-layout.ts b/src/migrations/sqlite/1621078163528-layout.ts index 85bb84c..cd86b05 100644 --- a/src/migrations/sqlite/1621078163528-layout.ts +++ b/src/migrations/sqlite/1621078163528-layout.ts @@ -4,11 +4,17 @@ export class layout1621078163528 implements MigrationInterface { name = 'layout1621078163528' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query('ALTER TABLE "form" ADD "designLayout" character varying'); + await queryRunner.query('CREATE TABLE "temporary_form" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "language" varchar(10) NOT NULL, "showFooter" boolean NOT NULL, "isLive" boolean NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "adminId" integer, "startPageId" integer, "endPageId" integer, "analyticsGacode" varchar, "designFont" varchar, "designColorsBackground" varchar, "designColorsQuestion" varchar, "designColorsAnswer" varchar, "designColorsButton" varchar, "designColorsButtonactive" varchar, "designColorsButtontext" varchar, "designLayout" varchar, CONSTRAINT "FK_e5d158932e43cfbf9958931ee01" FOREIGN KEY ("endPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_023d9cf1d97e93facc96c86ca70" FOREIGN KEY ("startPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_a7cb33580bca2b362e5e34fdfcd" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); + await queryRunner.query('INSERT INTO "temporary_form"("id", "title", "language", "showFooter", "isLive", "created", "lastModified", "adminId", "startPageId", "endPageId", "analyticsGacode", "designFont", "designColorsBackground", "designColorsQuestion", "designColorsAnswer", "designColorsButton", "designColorsButtonactive", "designColorsButtontext") SELECT "id", "title", "language", "showFooter", "isLive", "created", "lastModified", "adminId", "startPageId", "endPageId", "analyticsGacode", "designFont", "designColorsBackground", "designColorsQuestion", "designColorsAnswer", "designColorsButton", "designColorsButtonactive", "designColorsButtontext" FROM "form"'); + await queryRunner.query('DROP TABLE "form"'); + await queryRunner.query('ALTER TABLE "temporary_form" RENAME TO "form"'); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query('ALTER TABLE "form" DROP COLUMN "designLayout"'); + await queryRunner.query('ALTER TABLE "form" RENAME TO "temporary_form"'); + await queryRunner.query('CREATE TABLE "form" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "language" varchar(10) NOT NULL, "showFooter" boolean NOT NULL, "isLive" boolean NOT NULL, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "adminId" integer, "startPageId" integer, "endPageId" integer, "analyticsGacode" varchar, "designFont" varchar, "designColorsBackground" varchar, "designColorsQuestion" varchar, "designColorsAnswer" varchar, "designColorsButton" varchar, "designColorsButtonactive" varchar, "designColorsButtontext" varchar, CONSTRAINT "FK_e5d158932e43cfbf9958931ee01" FOREIGN KEY ("endPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_023d9cf1d97e93facc96c86ca70" FOREIGN KEY ("startPageId") REFERENCES "page" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_a7cb33580bca2b362e5e34fdfcd" FOREIGN KEY ("adminId") REFERENCES "user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); + await queryRunner.query('INSERT INTO "form"("id", "title", "language", "showFooter", "isLive", "created", "lastModified", "adminId", "startPageId", "endPageId", "analyticsGacode", "designFont", "designColorsBackground", "designColorsQuestion", "designColorsAnswer", "designColorsButton", "designColorsButtonactive", "designColorsButtontext") SELECT "id", "title", "language", "showFooter", "isLive", "created", "lastModified", "adminId", "startPageId", "endPageId", "analyticsGacode", "designFont", "designColorsBackground", "designColorsQuestion", "designColorsAnswer", "designColorsButton", "designColorsButtonactive", "designColorsButtontext" FROM "temporary_form"'); + await queryRunner.query('DROP TABLE "temporary_form"'); } } diff --git a/src/migrations/sqlite/1641124349039-submission.ts b/src/migrations/sqlite/1641124349039-submission.ts new file mode 100644 index 0000000..d918d1d --- /dev/null +++ b/src/migrations/sqlite/1641124349039-submission.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class submission1641124349039 implements MigrationInterface { + name = 'submission1641124349039' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('CREATE TABLE "temporary_submission_field" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "submissionId" integer, "fieldId" integer, "type" varchar NOT NULL, "content" text NOT NULL, CONSTRAINT "FK_5befa92da2370b7eb1cab6ae30a" FOREIGN KEY ("fieldId") REFERENCES "form_field" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_16fae661ce5b10f27abe2e524a0" FOREIGN KEY ("submissionId") REFERENCES "submission" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); + await queryRunner.query('INSERT INTO "temporary_submission_field"("id", "submissionId", "type", "content", "fieldId") SELECT "id", "submissionId", "fieldType", "fieldValue", "fieldId" FROM "submission_field"'); + await queryRunner.query('DROP TABLE "submission_field"'); + await queryRunner.query('ALTER TABLE "temporary_submission_field" RENAME TO "submission_field"'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "submission_field" RENAME TO "temporary_submission_field"'); + await queryRunner.query('CREATE TABLE "submission_field" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "fieldType" varchar NOT NULL, "fieldValue" varchar NOT NULL, "submissionId" integer, "fieldId" integer, CONSTRAINT "FK_5befa92da2370b7eb1cab6ae30a" FOREIGN KEY ("fieldId") REFERENCES "form_field" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION, CONSTRAINT "FK_16fae661ce5b10f27abe2e524a0" FOREIGN KEY ("submissionId") REFERENCES "submission" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION)'); + await queryRunner.query('INSERT INTO "submission_field"("id", "submissionId", "fieldType", "fieldValue", "fieldId") SELECT "id", "submissionId", "type", "content", "fieldId" FROM "temporary_submission_field"'); + await queryRunner.query('DROP TABLE "temporary_submission_field"'); + } + +} diff --git a/src/migrations/sqlite/1641132645227-confirm.ts b/src/migrations/sqlite/1641132645227-confirm.ts new file mode 100644 index 0000000..1fcd16a --- /dev/null +++ b/src/migrations/sqlite/1641132645227-confirm.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm' + +export class confirm1641132645227 implements MigrationInterface { + name = 'confirm1641132645227' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "firstName" varchar, "lastName" varchar, "email" varchar(255) NOT NULL, "username" varchar(255) NOT NULL, "passwordHash" varchar NOT NULL, "salt" varchar, "provider" varchar NOT NULL, "roles" text NOT NULL, "language" varchar NOT NULL, "resetPasswordToken" varchar, "resetPasswordExpires" datetime, "token" varchar, "apiKey" varchar, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), "emailVerified" boolean NOT NULL DEFAULT (0), CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))'); + await queryRunner.query('INSERT INTO "temporary_user"("id", "firstName", "lastName", "email", "username", "passwordHash", "salt", "provider", "roles", "language", "resetPasswordToken", "resetPasswordExpires", "token", "apiKey", "created", "lastModified") SELECT "id", "firstName", "lastName", "email", "username", "passwordHash", "salt", "provider", "roles", "language", "resetPasswordToken", "resetPasswordExpires", "token", "apiKey", "created", "lastModified" FROM "user"'); + await queryRunner.query('DROP TABLE "user"'); + await queryRunner.query('ALTER TABLE "temporary_user" RENAME TO "user"'); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE "user" RENAME TO "temporary_user"'); + await queryRunner.query('CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "firstName" varchar, "lastName" varchar, "email" varchar(255) NOT NULL, "username" varchar(255) NOT NULL, "passwordHash" varchar NOT NULL, "salt" varchar, "provider" varchar NOT NULL, "roles" text NOT NULL, "language" varchar NOT NULL, "resetPasswordToken" varchar, "resetPasswordExpires" datetime, "token" varchar, "apiKey" varchar, "created" datetime NOT NULL DEFAULT (datetime(\'now\')), "lastModified" datetime NOT NULL DEFAULT (datetime(\'now\')), CONSTRAINT "UQ_78a916df40e02a9deb1c4b75edb" UNIQUE ("username"), CONSTRAINT "UQ_e12875dfb3b1d92d7d7c5377e22" UNIQUE ("email"))'); + await queryRunner.query('INSERT INTO "user"("id", "firstName", "lastName", "email", "username", "passwordHash", "salt", "provider", "roles", "language", "resetPasswordToken", "resetPasswordExpires", "token", "apiKey", "created", "lastModified") SELECT "id", "firstName", "lastName", "email", "username", "passwordHash", "salt", "provider", "roles", "language", "resetPasswordToken", "resetPasswordExpires", "token", "apiKey", "created", "lastModified" FROM "temporary_user"'); + await queryRunner.query('DROP TABLE "temporary_user"'); + } +} diff --git a/src/resolver/form/form.delete.mutation.ts b/src/resolver/form/form.delete.mutation.ts index cbd68f8..bb95ae6 100644 --- a/src/resolver/form/form.delete.mutation.ts +++ b/src/resolver/form/form.delete.mutation.ts @@ -23,7 +23,7 @@ export class FormDeleteMutation { ): Promise { const form = await this.formService.findById(id) - if (!form.isLive && !await this.formService.isAdmin(form, user)) { + if (!form.isLive && !this.formService.isAdmin(form, user)) { throw new Error('invalid form') } diff --git a/src/resolver/form/form.query.ts b/src/resolver/form/form.query.ts index e1d23db..78b00c4 100644 --- a/src/resolver/form/form.query.ts +++ b/src/resolver/form/form.query.ts @@ -21,7 +21,7 @@ export class FormQuery { ): Promise { const form = await this.formService.findById(id) - if (!form.isLive && !await this.formService.isAdmin(form, user)) { + if (!form.isLive && !this.formService.isAdmin(form, user)) { throw new Error('invalid form') } diff --git a/src/resolver/form/form.resolver.ts b/src/resolver/form/form.resolver.ts index a04335f..727ba8e 100644 --- a/src/resolver/form/form.resolver.ts +++ b/src/resolver/form/form.resolver.ts @@ -51,7 +51,7 @@ export class FormResolver { ): Promise { const form = await cache.get(cache.getCacheKey(FormEntity.name, parent.id)) - if (!await this.formService.isAdmin(form, user)) { + if (!this.formService.isAdmin(form, user)) { throw new Error('no access to field') } @@ -67,7 +67,7 @@ export class FormResolver { ): Promise { const form = await cache.get(cache.getCacheKey(FormEntity.name, parent.id)) - if (!await this.formService.isAdmin(form, user)) { + if (!this.formService.isAdmin(form, user)) { throw new Error('no access to field') } diff --git a/src/resolver/form/form.update.mutation.ts b/src/resolver/form/form.update.mutation.ts index 2dd9f82..1d4f158 100644 --- a/src/resolver/form/form.update.mutation.ts +++ b/src/resolver/form/form.update.mutation.ts @@ -27,7 +27,7 @@ export class FormUpdateMutation { ): Promise { const form = await this.formService.findById(input.id) - if (!form.isLive && !await this.formService.isAdmin(form, user)) { + if (!form.isLive && !this.formService.isAdmin(form, user)) { throw new Error('invalid form') } diff --git a/src/resolver/profile/profile.resolver.ts b/src/resolver/profile/profile.resolver.ts index 0c02cd3..36be407 100644 --- a/src/resolver/profile/profile.resolver.ts +++ b/src/resolver/profile/profile.resolver.ts @@ -8,10 +8,10 @@ import { ContextCache } from '../context.cache' export class ProfileResolver { @Query(() => ProfileModel) @Roles('user') - async me( + public me( @User() user: UserEntity, @Context('cache') cache: ContextCache, - ): Promise { + ): ProfileModel { cache.add(cache.getCacheKey(UserEntity.name, user.id), user) return new ProfileModel(user) diff --git a/src/resolver/profile/profile.update.mutation.ts b/src/resolver/profile/profile.update.mutation.ts index aae9e3f..c289c7a 100644 --- a/src/resolver/profile/profile.update.mutation.ts +++ b/src/resolver/profile/profile.update.mutation.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common' import { Args, Context, Mutation } from '@nestjs/graphql' +import { Roles } from '../../decorator/roles.decorator' import { User } from '../../decorator/user.decorator' import { ProfileModel } from '../../dto/profile/profile.model' import { ProfileUpdateInput } from '../../dto/profile/profile.update.input' @@ -15,6 +16,7 @@ export class ProfileUpdateMutation { } @Mutation(() => ProfileModel) + @Roles('user') async updateProfile( @User() user: UserEntity, @Args({ name: 'user', type: () => ProfileUpdateInput }) input: ProfileUpdateInput, @@ -26,4 +28,18 @@ export class ProfileUpdateMutation { return new ProfileModel(user) } + + @Mutation(() => ProfileModel) + @Roles('user') + async verifyEmail( + @User() user: UserEntity, + @Args({ name: 'token' }) token: string, + @Context('cache') cache: ContextCache, + ): Promise { + await this.updateService.verifyEmail(user, token) + + cache.add(cache.getCacheKey(UserEntity.name, user.id), user) + + return new ProfileModel(user) + } } diff --git a/src/resolver/setting/setting.resolver.ts b/src/resolver/setting/setting.resolver.ts index d46bc47..3554881 100644 --- a/src/resolver/setting/setting.resolver.ts +++ b/src/resolver/setting/setting.resolver.ts @@ -27,14 +27,14 @@ export class SettingResolver { } @Query(() => SettingModel) - async getSetting( + getSetting( @Args('key', {type: () => ID}) key: string, @User() user: UserEntity, - ): Promise { + ): SettingModel { if (!this.settingService.isPublicKey(key) && !user.roles.includes('superuser')) { throw new Error(`no access to key ${key}`) } - return await this.settingService.getByKey(key) + return this.settingService.getByKey(key) } } diff --git a/src/service/form/form.delete.service.ts b/src/service/form/form.delete.service.ts index 2c17acb..13f9c3f 100644 --- a/src/service/form/form.delete.service.ts +++ b/src/service/form/form.delete.service.ts @@ -18,9 +18,11 @@ export class FormDeleteService { await this.submissionRepository.createQueryBuilder('s') .delete() .where('s.form = :form', { form: id }) + .execute() await this.formRepository.createQueryBuilder('f') .delete() .where('f.id = :form', { form: id }) + .execute() } } diff --git a/src/service/form/form.service.ts b/src/service/form/form.service.ts index 25e1e30..afc3056 100644 --- a/src/service/form/form.service.ts +++ b/src/service/form/form.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' +import { PinoLogger } from 'nestjs-pino' import { Repository } from 'typeorm' import { FormEntity } from '../../entity/form.entity' import { UserEntity } from '../../entity/user.entity' @@ -9,10 +10,12 @@ export class FormService { constructor( @InjectRepository(FormEntity) private readonly formRepository: Repository, + private readonly logger: PinoLogger, ) { + logger.setContext(this.constructor.name) } - async isAdmin(form: FormEntity, user: UserEntity): Promise { + isAdmin(form: FormEntity, user: UserEntity): boolean { if (!user) { return false } @@ -24,7 +27,12 @@ export class FormService { return form.admin.id === user.id } - async find(start: number, limit: number, sort: any = {}, user?: UserEntity): Promise<[FormEntity[], number]> { + async find( + start: number, + limit: number, + sort: any = {}, + user?: UserEntity + ): Promise<[FormEntity[], number]> { const qb = this.formRepository.createQueryBuilder('f') qb.leftJoinAndSelect('f.admin', 'a') @@ -34,6 +42,9 @@ export class FormService { } // TODO readd sort + this.logger.debug({ + sort, + }, 'ignored sorting for submissions') qb.skip(start) qb.take(limit) diff --git a/src/service/form/form.update.service.ts b/src/service/form/form.update.service.ts index fd2f249..194dc1e 100644 --- a/src/service/form/form.update.service.ts +++ b/src/service/form/form.update.service.ts @@ -41,8 +41,12 @@ export class FormUpdateService { } if (input.fields !== undefined) { - form.fields = await Promise.all(input.fields.map(async (nextField) => { - let field = form.fields.find(field => field.id?.toString() === nextField.id) + form.fields = input.fields.map((nextField) => { + let field = this.findByIdInList( + form.fields, + nextField.id, + null + ) if (!field) { field = new FormFieldEntity() @@ -75,7 +79,11 @@ export class FormUpdateService { if (nextField.logic !== undefined) { field.logic = nextField.logic.map(nextLogic => { - const logic = field.logic?.find(logic => logic.id?.toString() === nextLogic.id) || new FormFieldLogicEntity() + const logic = this.findByIdInList( + field.logic, + nextLogic.id, + new FormFieldLogicEntity() + ) logic.field = field @@ -83,7 +91,7 @@ export class FormUpdateService { logic.formula = nextLogic.formula } if (nextLogic.action !== undefined) { - logic.action = nextLogic.action as any + logic.action = nextLogic.action } if (nextLogic.visible !== undefined) { logic.visible = nextLogic.visible @@ -95,7 +103,11 @@ export class FormUpdateService { logic.disable = nextLogic.disable } if (nextLogic.jumpTo !== undefined) { - logic.jumpTo = form.fields.find(value => value.id?.toString() === nextLogic.jumpTo) + logic.jumpTo = this.findByIdInList( + form.fields, + nextLogic.jumpTo, + null + ) } if (nextLogic.enabled !== undefined) { logic.enabled = nextLogic.enabled @@ -107,7 +119,11 @@ export class FormUpdateService { if (nextField.options !== undefined) { field.options = nextField.options.map(nextOption => { - const option = field.options?.find(option => option.id?.toString() === nextOption.id) || new FormFieldOptionEntity() + const option = this.findByIdInList( + field.options, + nextOption.id, + new FormFieldOptionEntity() + ) option.field = field @@ -124,13 +140,16 @@ export class FormUpdateService { } return field - })) - + }) } if (input.hooks !== undefined) { form.hooks = input.hooks.map((nextHook) => { - const hook = form.hooks?.find(hook => hook.id?.toString() === nextHook.id) || new FormHookEntity() + const hook = this.findByIdInList( + form.hooks, + nextHook.id, + new FormHookEntity() + ) // ability for other fields to apply mapping hook.url = nextHook.url @@ -179,7 +198,11 @@ export class FormUpdateService { if (input.notifications !== undefined) { form.notifications = input.notifications.map(notificationInput => { - const notification = form.notifications?.find(value => value.id?.toString() === notificationInput.id) || new FormNotificationEntity() + const notification = this.findByIdInList( + form.notifications, + notificationInput.id, + new FormNotificationEntity() + ) notification.form = form notification.enabled = notificationInput.enabled @@ -188,7 +211,11 @@ export class FormUpdateService { notification.fromEmail = notificationInput.fromEmail } if (notificationInput.fromField !== undefined) { - notification.fromField = form.fields.find(value => value.id?.toString() === notificationInput.fromField) + notification.fromField = this.findByIdInList( + form.fields, + notificationInput.fromField, + null + ) } if (notificationInput.subject !== undefined) { notification.subject = notificationInput.subject @@ -200,7 +227,11 @@ export class FormUpdateService { notification.toEmail = notificationInput.toEmail } if (notificationInput.toField !== undefined) { - notification.toField = form.fields.find(value => value.id?.toString() === notificationInput.toField) + notification.toField = this.findByIdInList( + form.fields, + notificationInput.toField, + null + ) } return notification @@ -240,7 +271,11 @@ export class FormUpdateService { if (input.startPage.buttons !== undefined) { form.startPage.buttons = input.startPage.buttons.map(buttonInput => { - const entity = form.startPage?.buttons?.find(value => value.id?.toString() === buttonInput.id) || new PageButtonEntity() + const entity = this.findByIdInList( + form.startPage?.buttons, + buttonInput.id, + new PageButtonEntity() + ) entity.page = form.startPage entity.url = buttonInput.url entity.action = buttonInput.action @@ -278,7 +313,11 @@ export class FormUpdateService { if (input.endPage.buttons !== undefined) { form.endPage.buttons = input.endPage.buttons.map(buttonInput => { - const entity = form.endPage?.buttons?.find(value => value.id?.toString() === buttonInput.id) || new PageButtonEntity() + const entity = this.findByIdInList( + form.endPage?.buttons, + buttonInput.id, + new PageButtonEntity() + ) entity.page = form.endPage entity.url = buttonInput.url entity.action = buttonInput.action @@ -296,4 +335,18 @@ export class FormUpdateService { return form } + + private findByIdInList(list: T[], id: string, fallback: T): T { + if (!list) { + return fallback + } + + const found = list.find((value: any) => String(value.id) === String(id)) + + if (found) { + return found + } + + return fallback + } } diff --git a/src/service/installation.metrics.service.ts b/src/service/installation.metrics.service.ts index 72bd18c..4b62577 100644 --- a/src/service/installation.metrics.service.ts +++ b/src/service/installation.metrics.service.ts @@ -14,7 +14,7 @@ export class InstallationMetricsService implements OnApplicationBootstrap { logger.setContext(this.constructor.name) } - async onApplicationBootstrap(): Promise { + onApplicationBootstrap(): void { if (this.configService.get('DISABLE_INSTALLATION_METRICS')) { this.logger.info('installation metrics are disabled') return @@ -42,6 +42,4 @@ export class InstallationMetricsService implements OnApplicationBootstrap { }) }, 24 * 60 * 60 * 1000) } - - } diff --git a/src/service/mail.service.ts b/src/service/mail.service.ts index 558483c..56dece1 100644 --- a/src/service/mail.service.ts +++ b/src/service/mail.service.ts @@ -7,6 +7,7 @@ import htmlToText from 'html-to-text' import mjml2html from 'mjml' import { PinoLogger } from 'nestjs-pino' import { join } from 'path' +import { serializeError } from 'serialize-error' import { defaultLanguage } from '../config/languages' @Injectable() @@ -19,10 +20,19 @@ export class MailService { logger.setContext(this.constructor.name) } - async send(to: string, template: string, context: { [key: string]: any }, language: string = defaultLanguage): Promise { - this.logger.info({ + async send( + to: string, + template: string, + context: { [key: string]: any }, + forceLanguage?: string + ): Promise { + const language = forceLanguage || this.configService.get('LOCALE', defaultLanguage) + + this.logger.debug({ email: to, - }, `send email ${template}`) + template, + }, 'try to send email') + try { const path = this.getTemplatePath(template, language) @@ -35,23 +45,36 @@ export class MailService { } ).html - const text = htmlToText.fromString(html) + const text = htmlToText.htmlToText(html) - const subject = /(.*?)<\/title>/gi.test(html) ? /<title>(.*?)<\/title>/gi.exec(html)[1] : template + const subject = this.extractSubject(html, template) await this.nestMailer.sendMail({ to, subject, html, text }) - this.logger.info('sent email') + this.logger.info({ + email: to, + template, + language, + }, 'sent email') } catch (error) { this.logger.error({ - error: error.message, + error: serializeError(error), email: to, - }, `failed to send email ${template}`) + template, + }, 'failed to send email') return false } return true } + private extractSubject(html: string, template: string): string { + if (/<title>(.*?)<\/title>/gi.test(html)) { + return /<title>(.*?)<\/title>/gi.exec(html)[1] + } + + return template + } + private getTemplatePath(template: string, language: string): string { let templatePath = join(this.configService.get<string>('LOCALES_PATH'), language, 'mail', `${template}.mjml`) diff --git a/src/service/profile/profile.update.service.ts b/src/service/profile/profile.update.service.ts index bd573a3..bb68a8b 100644 --- a/src/service/profile/profile.update.service.ts +++ b/src/service/profile/profile.update.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm' import { ProfileUpdateInput } from '../../dto/profile/profile.update.input' import { UserEntity } from '../../entity/user.entity' import { PasswordService } from '../auth/password.service' +import { UserTokenService } from '../user/user.token.service' @Injectable() export class ProfileUpdateService { @@ -11,9 +12,18 @@ export class ProfileUpdateService { @InjectRepository(UserEntity) private readonly userRepository: Repository<UserEntity>, private readonly passwordService: PasswordService, + private readonly userTokenService: UserTokenService, ) { } + async verifyEmail(user: UserEntity, token: string): Promise<UserEntity> { + if (!await this.userTokenService.verify(token, user.token)) { + throw new Error('invalid token') + } + + return await this.userRepository.save(user) + } + async update(user: UserEntity, input: ProfileUpdateInput): Promise<UserEntity> { if (input.firstName !== undefined) { user.firstName = input.firstName @@ -25,6 +35,7 @@ export class ProfileUpdateService { if (input.email !== undefined && user.email !== input.email) { user.email = input.email + user.emailVerified = false // TODO request email verification if (undefined !== await this.userRepository.findOne({ email: input.email })) { diff --git a/src/service/setting.service.ts b/src/service/setting.service.ts index 887861f..e9e41e2 100644 --- a/src/service/setting.service.ts +++ b/src/service/setting.service.ts @@ -17,7 +17,7 @@ export class SettingService { ].includes(key) } - async getByKey(key: string): Promise<SettingModel> { + getByKey(key: string): SettingModel { switch (key) { case 'SIGNUP_DISABLED': case 'LOGIN_NOTE': @@ -29,11 +29,11 @@ export class SettingService { throw new Error(`no config stored for key ${key}`) } - async isTrue(key: string): Promise<boolean> { - return (await this.getByKey(key)).isTrue + isTrue(key: string): boolean { + return this.getByKey(key).isTrue } - async isFalse(key: string): Promise<boolean> { - return (await this.getByKey(key)).isFalse + isFalse(key: string): boolean { + return this.getByKey(key).isFalse } } diff --git a/src/service/submission/submission.hook.service.ts b/src/service/submission/submission.hook.service.ts index 4719586..ce9d237 100644 --- a/src/service/submission/submission.hook.service.ts +++ b/src/service/submission/submission.hook.service.ts @@ -2,6 +2,8 @@ import { HttpService } from '@nestjs/axios' import { Injectable } from '@nestjs/common' import handlebars from 'handlebars' import { PinoLogger } from 'nestjs-pino' +import { lastValueFrom } from 'rxjs' +import { serializeError } from 'serialize-error' import { SubmissionEntity } from '../../entity/submission.entity' @Injectable() @@ -20,15 +22,19 @@ export class SubmissionHookService { } try { - const response = await this.httpService.post( + const response = await lastValueFrom(this.httpService.post( hook.url, this.format(submission, hook.format) - ).toPromise() + )) console.log('sent hook', response.data) } catch (e) { - this.logger.error(`failed to post to "${hook.url}: ${e.message}`) - this.logger.error(e.stack) + this.logger.error({ + submission: submission.id, + form: submission.formId, + webhook: hook.url, + error: serializeError(e), + }, 'failed to post webhook') throw e } })) diff --git a/src/service/submission/submission.notification.service.ts b/src/service/submission/submission.notification.service.ts index 9c51db5..6c48add 100644 --- a/src/service/submission/submission.notification.service.ts +++ b/src/service/submission/submission.notification.service.ts @@ -4,6 +4,7 @@ import handlebars from 'handlebars' import htmlToText from 'html-to-text' import mjml2html from 'mjml' import { PinoLogger } from 'nestjs-pino' +import { serializeError } from 'serialize-error' import { SubmissionEntity } from '../../entity/submission.entity' @Injectable() @@ -22,15 +23,25 @@ export class SubmissionNotificationService { } try { - const to = this.getEmail(submission.fields.find(field => field.fieldId === notification.toField.id )?.fieldValue, notification.toEmail) - const from = this.getEmail(submission.fields.find(field => field.fieldId === notification.fromField.id )?.fieldValue, notification.fromEmail) + const to = this.getEmail( + submission, + notification.toField.id, + notification.toEmail + ) + const from = this.getEmail( + submission, + notification.fromField.id, + notification.fromEmail + ) - const html = mjml2html( - handlebars.compile( - notification.htmlTemplate - )({ + const template = handlebars.compile( + notification.htmlTemplate + ) + + const html: string = mjml2html( + template({ // TODO add variables - }), + }) , { minify: true, } @@ -41,29 +52,30 @@ export class SubmissionNotificationService { replyTo: from, subject: notification.subject, html, - text: htmlToText.fromString(html), + text: htmlToText.htmlToText(html), }) console.log('sent notification to', to) } catch (e) { - this.logger.error(e.stack) + this.logger.error({ + form: submission.formId, + submission: submission.id, + notification: notification.id, + error: serializeError(e), + }, 'failed to process notification') throw e } })) } - private getEmail(raw: string, fallback: string): string { - if (!raw) { + private getEmail(submission: SubmissionEntity, fieldId: number, fallback: string): string { + const data = submission.fields.find(field => field.fieldId === fieldId)?.content + + if (!data) { return fallback } - try { - const data = JSON.parse(raw) - - if (data.value) { - return data.value - } - } catch (e) { - this.logger.error('could not decode field value', raw) + if (typeof data.value === 'string') { + return data.value } return fallback diff --git a/src/service/submission/submission.service.ts b/src/service/submission/submission.service.ts index ce57afa..ba3ac1e 100644 --- a/src/service/submission/submission.service.ts +++ b/src/service/submission/submission.service.ts @@ -1,4 +1,5 @@ import { InjectRepository } from '@nestjs/typeorm' +import { PinoLogger } from 'nestjs-pino' import { Repository } from 'typeorm' import { FormEntity } from '../../entity/form.entity' import { SubmissionEntity } from '../../entity/submission.entity' @@ -8,15 +9,22 @@ export class SubmissionService { constructor( @InjectRepository(SubmissionEntity) private readonly submissionRepository: Repository<SubmissionEntity>, - private readonly tokenService: SubmissionTokenService + private readonly tokenService: SubmissionTokenService, + private readonly logger: PinoLogger, ) { + this.logger.setContext(this.constructor.name) } async isOwner(submission: SubmissionEntity, token: string): Promise<boolean> { - return await this.tokenService.verify(token, submission.tokenHash) + return this.tokenService.verify(token, submission.tokenHash) } - async find(form: FormEntity, start: number, limit: number, sort: any = {}): Promise<[SubmissionEntity[], number]> { + async find( + form: FormEntity, + start: number, + limit: number, + sort: any = {} + ): Promise<[SubmissionEntity[], number]> { const qb = this.submissionRepository.createQueryBuilder('s') qb.leftJoinAndSelect('s.fields', 'fields') @@ -24,6 +32,9 @@ export class SubmissionService { qb.where('s.form = :form', { form: form.id }) // TODO readd sort + this.logger.debug({ + sort, + }, 'ignored sorting for submissions') qb.skip(start) qb.take(limit) diff --git a/src/service/submission/submission.set.field.service.ts b/src/service/submission/submission.set.field.service.ts index 07cddc4..10f1f2d 100644 --- a/src/service/submission/submission.set.field.service.ts +++ b/src/service/submission/submission.set.field.service.ts @@ -2,10 +2,11 @@ import { Injectable } from '@nestjs/common' import { InjectRepository } from '@nestjs/typeorm' import dayjs from 'dayjs' import { PinoLogger } from 'nestjs-pino' +import { serializeError } from 'serialize-error' import { Repository } from 'typeorm' import { SubmissionSetFieldInput } from '../../dto/submission/submission.set.field.input' import { SubmissionEntity } from '../../entity/submission.entity' -import { SubmissionFieldEntity } from '../../entity/submission.field.entity' +import { SubmissionFieldContent, SubmissionFieldEntity } from '../../entity/submission.field.entity' import { SubmissionHookService } from './submission.hook.service' import { SubmissionNotificationService } from './submission.notification.service' @@ -27,7 +28,7 @@ export class SubmissionSetFieldService { let field = submission.fields.find(field => field.field.id.toString() === input.field) if (field) { - field.fieldValue = JSON.parse(input.data) + field.content = this.parseData(field, input.data) await this.submissionFieldRepository.save(field) } else { @@ -35,8 +36,8 @@ export class SubmissionSetFieldService { field.submission = submission field.field = submission.form.fields.find(field => field.id.toString() === input.field) - field.fieldType = field.field.type - field.fieldValue = JSON.parse(input.data) + field.type = field.field.type + field.content = this.parseData(field, input.data) field = await this.submissionFieldRepository.save(field) @@ -51,11 +52,103 @@ export class SubmissionSetFieldService { if (submission.percentageComplete === 1) { this.webHook.process(submission).catch(e => { - this.logger.error(`failed to send webhooks: ${e.message}`) + this.logger.error({ + submission: submission.id, + form: submission.formId, + error: serializeError(e), + }, 'failed to send webhooks') }) this.notifications.process(submission).catch(e => { - this.logger.error(`failed to send notifications: ${e.message}`) + this.logger.error({ + submission: submission.id, + form: submission.formId, + error: serializeError(e), + }, 'failed to send notifications') }) } } + + private parseData( + field: SubmissionFieldEntity, + data: string + ): SubmissionFieldContent { + let raw: { [key: string]: unknown } + + const context = { + field: field.fieldId, + type: field.type, + } + + try { + raw = JSON.parse(data) as { [key: string]: unknown } + } catch (e) { + this.logger.warn(context, 'received invalid data for field') + return null + } + + if (typeof raw !== 'object' || Array.isArray(raw)) { + this.logger.warn(context, 'only object supported for data') + return null + } + + // now ensure data structure + const result = { + value: null, + } + + let valid = true + + Object.keys(raw).forEach((key) => { + const value = raw[String(key)] + + switch (typeof value) { + case 'number': + case 'string': + case 'boolean': + result[String(key)] = value + return + } + + if (Array.isArray(value)) { + result[String(key)] = value.map((row: unknown, index) => { + switch (typeof value) { + case 'number': + case 'string': + case 'boolean': + case 'undefined': + return row + } + + if (value === null) { + return row + } + + this.logger.warn({ + ...context, + path: `${key}/${index}`, + }, 'invalid data in array') + valid = false + + return null + }) + + return + } + + this.logger.warn({ + ...context, + path: String(key), + + }, 'invalid data in entry') + + valid = false + }) + + if (!valid) { + this.logger.warn(context, 'invalid data in object entries') + return null + } + + return result + } } diff --git a/src/service/submission/submission.start.service.ts b/src/service/submission/submission.start.service.ts index 408adee..c885bc6 100644 --- a/src/service/submission/submission.start.service.ts +++ b/src/service/submission/submission.start.service.ts @@ -30,6 +30,8 @@ export class SubmissionStartService { submission.timeElapsed = 0 submission.percentageComplete = 0 + // TODO set country! + submission.device.language = input.device.language submission.device.name = input.device.name submission.device.type = input.device.type diff --git a/src/service/submission/submission.token.service.ts b/src/service/submission/submission.token.service.ts index 4a7687c..1490a48 100644 --- a/src/service/submission/submission.token.service.ts +++ b/src/service/submission/submission.token.service.ts @@ -1,12 +1,13 @@ import { Injectable } from '@nestjs/common' +import * as bcrypt from 'bcrypt' @Injectable() export class SubmissionTokenService { async hash(token: string): Promise<string> { - return token + return bcrypt.hash(token, 4) } async verify(token: string, hash: string): Promise<boolean> { - return token == hash + return await bcrypt.compare(token, hash) } } diff --git a/src/service/user/boot.service.ts b/src/service/user/boot.service.ts index 76e011f..83f25e1 100644 --- a/src/service/user/boot.service.ts +++ b/src/service/user/boot.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnApplicationBootstrap } from '@nestjs/common' import { ConfigService } from '@nestjs/config' import { PinoLogger } from 'nestjs-pino' +import { serializeError } from 'serialize-error' import { UserCreateService } from './user.create.service' import { UserService } from './user.service' @@ -30,19 +31,15 @@ export class BootService implements OnApplicationBootstrap { const email = this.configService.get<string>('ADMIN_EMAIL', 'admin@ohmyform.com') const password = this.configService.get<string>('ADMIN_PASSWORD', 'root') - try { - await this.userService.findByUsername(username) - + if (await this.userService.usernameInUse(username)) { this.logger.info('username already exists, skip creating') return - } catch (e) {} - - try { - await this.userService.findByEmail(email) + } + if (await this.userService.emailInUse(email)) { this.logger.info('email already exists, skip creating') return - } catch (e) {} + } try { await this.createService.create({ @@ -54,7 +51,7 @@ export class BootService implements OnApplicationBootstrap { ]) } catch (e) { this.logger.error({ - error: e, + error: serializeError(e), }, 'could not create admin user') return } diff --git a/src/service/user/index.ts b/src/service/user/index.ts index a215ed1..3ad6c27 100644 --- a/src/service/user/index.ts +++ b/src/service/user/index.ts @@ -3,6 +3,7 @@ import { UserCreateService } from './user.create.service' import { UserDeleteService } from './user.delete.service' import { UserService } from './user.service' import { UserStatisticService } from './user.statistic.service' +import { UserTokenService } from './user.token.service' import { UserUpdateService } from './user.update.service' export const userServices = [ @@ -11,5 +12,6 @@ export const userServices = [ UserDeleteService, UserService, UserStatisticService, + UserTokenService, UserUpdateService, ] diff --git a/src/service/user/user.create.service.ts b/src/service/user/user.create.service.ts index b2a763b..fc2a587 100644 --- a/src/service/user/user.create.service.ts +++ b/src/service/user/user.create.service.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' import { InjectRepository } from '@nestjs/typeorm' +import crypto from 'crypto' import { PinoLogger } from 'nestjs-pino' import { Repository } from 'typeorm' import { rolesType } from '../../config/roles' @@ -8,6 +10,7 @@ import { UserEntity } from '../../entity/user.entity' import { PasswordService } from '../auth/password.service' import { MailService } from '../mail.service' import { SettingService } from '../setting.service' +import { UserTokenService } from './user.token.service' @Injectable() export class UserCreateService { @@ -18,12 +21,14 @@ export class UserCreateService { private readonly logger: PinoLogger, private readonly passwordService: PasswordService, private readonly settingService: SettingService, + private readonly configService: ConfigService, + private readonly userTokenService: UserTokenService, ) { logger.setContext(this.constructor.name) } - private async getDefaultRoles(): Promise<rolesType> { - const roleSetting = await this.settingService.getByKey('DEFAULT_ROLE') + private getDefaultRoles(): rolesType { + const roleSetting = this.settingService.getByKey('DEFAULT_ROLE') switch (roleSetting.value) { case 'superuser': @@ -49,6 +54,8 @@ export class UserCreateService { throw new Error('email already in use') } + const confirmToken = crypto.randomBytes(30).toString('base64') + let user = new UserEntity() user.provider = 'local' @@ -57,17 +64,25 @@ export class UserCreateService { user.firstName = input.firstName user.lastName = input.lastName user.language = input.language ?? 'en' - user.roles = roles ? roles : await this.getDefaultRoles() + user.roles = roles ? roles : this.getDefaultRoles() user.passwordHash = await this.passwordService.hash(input.password) + user.token = await this.userTokenService.hash(confirmToken) user = await this.userRepository.save(user) + const confirmUrl = [ + this.configService.get('BASE_URL', 'http://localhost'), + this.configService.get('USER_CONFIRM_PATH', '/confirm?token={{token}}'), + ] + .join('') + .replace('{{token}}', confirmToken) + const sent = await this.mailerService.send( user.email, 'user/created', { username: user.username, - confirm: 'https://www.google.com', // TODO confirm url + confirm: confirmUrl, } ) diff --git a/src/service/user/user.service.ts b/src/service/user/user.service.ts index 30b81b8..c847650 100644 --- a/src/service/user/user.service.ts +++ b/src/service/user/user.service.ts @@ -59,4 +59,16 @@ export class UserService { return user } + + async usernameInUse(username: string): Promise<boolean> { + return 0 !== await this.userRepository.count({ + username, + }) + } + + async emailInUse(email: string): Promise<boolean> { + return 0 !== await this.userRepository.count({ + email, + }) + } } diff --git a/src/service/user/user.token.service.ts b/src/service/user/user.token.service.ts new file mode 100644 index 0000000..0e6d431 --- /dev/null +++ b/src/service/user/user.token.service.ts @@ -0,0 +1,13 @@ +import { Injectable } from '@nestjs/common' +import * as bcrypt from 'bcrypt' + +@Injectable() +export class UserTokenService { + async hash(token: string): Promise<string> { + return bcrypt.hash(token, 4) + } + + async verify(token: string, hash: string): Promise<boolean> { + return await bcrypt.compare(token, hash) + } +} diff --git a/src/service/user/user.update.service.ts b/src/service/user/user.update.service.ts index e81600e..01d44b0 100644 --- a/src/service/user/user.update.service.ts +++ b/src/service/user/user.update.service.ts @@ -26,6 +26,12 @@ export class UserUpdateService { if (input.email !== undefined) { user.email = input.email + user.emailVerified = false + // TODO request email verification + + if (undefined !== await this.userRepository.findOne({ email: input.email })) { + throw new Error('email already in use') + } } if (input.username !== undefined) { diff --git a/yarn.lock b/yarn.lock index a9d3856..8984f8b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -898,7 +898,7 @@ tslib "~2.3.0" value-or-promise "1.0.11" -"@graphql-tools/utils@7.10.0": +"@graphql-tools/utils@7.10.0", "@graphql-tools/utils@^7.0.0": version "7.10.0" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.10.0.tgz#07a4cb5d1bec1ff1dc1d47a935919ee6abd38699" integrity sha512-d334r6bo9mxdSqZW6zWboEnnOOFRrAPVQJ7LkU8/6grglrbcu6WhwCLzHb90E94JI3TD3ricC3YGbUqIi9Xg0w== @@ -907,7 +907,7 @@ camel-case "4.1.2" tslib "~2.2.0" -"@graphql-tools/utils@^7.0.0", "@graphql-tools/utils@^7.1.2": +"@graphql-tools/utils@^7.1.2": version "7.8.0" resolved "https://registry.yarnpkg.com/@graphql-tools/utils/-/utils-7.8.0.tgz#74290863b5c84c1bf1d8749e7a05b1b029a0c55e" integrity sha512-nORIltDwBdsc3Ew+vuXISTZw6gpRd3UkK+6HNY3knNca6apTDj7ygcJmgsFKjSyUf7xtukddTcF6Js9kPuJr6g== @@ -1781,6 +1781,18 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.1.tgz#283f669ff76d7b8260df8ab7a4262cc83d988256" integrity sha512-fZQQafSREFyuZcdWFAExYjBiCL7AUCdgsk80iO0q4yihYYdcIiH28CcuPTGFgLOCC8RlW49GSQxdHwZP+I7CNg== +"@types/mjml-core@*": + version "4.7.1" + resolved "https://registry.yarnpkg.com/@types/mjml-core/-/mjml-core-4.7.1.tgz#c2627499045b54eccfca38e2b532566fb0689189" + integrity sha512-k5IRafi93tyZBGF+0BTrcBDvG47OueI+Q7TC4V4UjGQn0AMVvL3Y+S26QF/UHMmMJW5r1hxLyv3StX2/+FatFg== + +"@types/mjml@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@types/mjml/-/mjml-4.7.0.tgz#ea31b58008f54119efda9e673af674757d35981b" + integrity sha512-aWWu8Lxq2SexXGs+lBPRUpN3kFf0sDRo3Y4jz7BQ15cQvMfyZOadgFJsNlHmDqI6D2Qjx0PIK+1f9IMXgq9vTA== + dependencies: + "@types/mjml-core" "*" + "@types/node@*": version "14.6.2" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.6.2.tgz#264b44c5a28dfa80198fc2f7b6d3c8a054b9491f"