add user confirmation, add validation for submission data

This commit is contained in:
Michael Schramm 2022-01-02 15:41:20 +01:00
parent 11e95cb9c2
commit 29a74ea9c9
52 changed files with 573 additions and 142 deletions

View File

@ -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',

View File

@ -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

View File

@ -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 <no-reply@localhost>` | 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 |

View File

@ -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"
}
}

View File

@ -12,6 +12,7 @@
"migrations": [
"src/migrations/postgres/**/*.ts"
],
"migrationsTransactionMode": "each",
"subscribers": [
"src/subscriber/**/*.ts"
],

View File

@ -9,6 +9,7 @@
"migrations": [
"src/migrations/sqlite/**/*.ts"
],
"migrationsTransactionMode": "each",
"cli": {
"migrationsDir": "src/migrations/sqlite"
}

View File

@ -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",

View File

@ -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<boolean>('DATABASE_MIGRATE', true),
migrationsTransactionMode: 'each',
})
},
}),

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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,
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class submission1641124349039 implements MigrationInterface {
name = 'submission1641124349039'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`');
}
}

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class confirm1641132645227 implements MigrationInterface {
name = 'confirm1641132645227'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `user` ADD `emailVerified` tinyint NOT NULL DEFAULT 0');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE `user` DROP COLUMN `emailVerified`');
}
}

View File

@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class submission1641124349039 implements MigrationInterface {
name = 'submission1641124349039'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"');
}
}

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class confirm1641132645227 implements MigrationInterface {
name = 'confirm1641132645227'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "user" ADD "emailVerified" boolean NOT NULL DEFAULT false');
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('ALTER TABLE "user" DROP COLUMN "emailVerified"');
}
}

View File

@ -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)');
}

View File

@ -4,11 +4,17 @@ export class layout1621078163528 implements MigrationInterface {
name = 'layout1621078163528'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"');
}
}

View File

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class submission1641124349039 implements MigrationInterface {
name = 'submission1641124349039'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"');
}
}

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm'
export class confirm1641132645227 implements MigrationInterface {
name = 'confirm1641132645227'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"');
}
}

View File

@ -23,7 +23,7 @@ export class FormDeleteMutation {
): Promise<DeletedModel> {
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')
}

View File

@ -21,7 +21,7 @@ export class FormQuery {
): Promise<FormModel> {
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')
}

View File

@ -51,7 +51,7 @@ export class FormResolver {
): Promise<boolean> {
const form = await cache.get<FormEntity>(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<FormNotificationModel[]> {
const form = await cache.get<FormEntity>(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')
}

View File

@ -27,7 +27,7 @@ export class FormUpdateMutation {
): Promise<FormModel> {
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')
}

View File

@ -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> {
): ProfileModel {
cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
return new ProfileModel(user)

View File

@ -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<ProfileModel> {
await this.updateService.verifyEmail(user, token)
cache.add(cache.getCacheKey(UserEntity.name, user.id), user)
return new ProfileModel(user)
}
}

View File

@ -27,14 +27,14 @@ export class SettingResolver {
}
@Query(() => SettingModel)
async getSetting(
getSetting(
@Args('key', {type: () => ID}) key: string,
@User() user: UserEntity,
): Promise<SettingModel> {
): 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)
}
}

View File

@ -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()
}
}

View File

@ -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<FormEntity>,
private readonly logger: PinoLogger,
) {
logger.setContext(this.constructor.name)
}
async isAdmin(form: FormEntity, user: UserEntity): Promise<boolean> {
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)

View File

@ -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<T>(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
}
}

View File

@ -14,7 +14,7 @@ export class InstallationMetricsService implements OnApplicationBootstrap {
logger.setContext(this.constructor.name)
}
async onApplicationBootstrap(): Promise<void> {
onApplicationBootstrap(): void {
if (this.configService.get<boolean>('DISABLE_INSTALLATION_METRICS')) {
this.logger.info('installation metrics are disabled')
return
@ -42,6 +42,4 @@ export class InstallationMetricsService implements OnApplicationBootstrap {
})
}, 24 * 60 * 60 * 1000)
}
}

View File

@ -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<boolean> {
this.logger.info({
async send(
to: string,
template: string,
context: { [key: string]: any },
forceLanguage?: string
): Promise<boolean> {
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>(.*?)<\/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`)

View File

@ -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 })) {

View File

@ -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
}
}

View File

@ -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
}
}))

View File

@ -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

View File

@ -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)

View File

@ -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
}
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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,
]

View File

@ -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,
}
)

View File

@ -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,
})
}
}

View File

@ -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)
}
}

View File

@ -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) {

View File

@ -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"