base system for graphql

This commit is contained in:
Michael Schramm 2020-05-08 22:40:14 +02:00
commit faa4aa48eb
82 changed files with 12771 additions and 0 deletions

4
.dockerignore Normal file
View File

@ -0,0 +1,4 @@
/dist
/node_modules
/data
/.git

24
.eslintrc.js Normal file
View File

@ -0,0 +1,24 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
project: 'tsconfig.json',
sourceType: 'module',
},
plugins: ['@typescript-eslint/eslint-plugin'],
extends: [
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier',
'prettier/@typescript-eslint',
],
root: true,
env: {
node: true,
jest: true,
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
},
};

39
.gitignore vendored Normal file
View File

@ -0,0 +1,39 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
# local data
/data
/.env
/src/schema.gql

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

22
Dockerfile Normal file
View File

@ -0,0 +1,22 @@
FROM node:12 AS builder
MAINTAINER OhMyForm <admin@ohmyform.com>
WORKDIR /usr/src/app
# just copy everhing
COPY . .
RUN yarn install --frozen-lockfile
RUN yarn build
FROM node:12-alpine
WORKDIR /usr/src/app
COPY --from=builder /usr/src/app /usr/src/app
ENV PORT=3000
EXPOSE 3000
CMD [ "yarn", "start:prod" ]

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# OhMyForm API
## Description
[OhMyForm](https://github.com/ohmyforn) api backend
All calls to the api are through GraphQL, with the endpoint
providing an introspectable schema at `GET /graphql`
## Installation
```bash
$ npm install
```
## Running the app
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
```
## Test
```bash
# unit tests
$ npm run test
# e2e tests
$ npm run test:e2e
# test coverage
$ npm run test:cov
```
## Support
TODO
## Stay in touch
- Website - [https://ohmyform.com](https://ohmyform.com/)
## License
TODO

24
doc/roles.md Normal file
View File

@ -0,0 +1,24 @@
# Roles
## unauthenticated
every request is unauthenticated unless an `Authorization`
header with a valid `JWT Bearer` token is provided.
## user
any new registration is a user per default, they can only see their
responses and do not have access to forms.
## admin
an admin can create forms and edit their own forms. They do not
have access to forms from other users.
## superuser
a superuser can create and edit any form on the platform as well as
modify any user
they can also grant a user admin or superuser access, they cannot revoke
their own superuser role

37
docker-compose.yml Normal file
View File

@ -0,0 +1,37 @@
version: "3"
services:
mongo:
image: mongo
ports:
- "27017:27017"
volumes:
- "./data/mongo:/data"
# api:
# build: .
# volumes:
# - ".:/usr/src/app"
# environment:
# MONGODB_URI: mongodb://mongo/ohmyform
# MAILER_URI: smtp://mail:1025
# command: yarn start:dev
# links:
# - mongo
# - mail
# ports:
# - "6000:3000"
# depends_on:
# - mongo
mail:
image: mailhog/mailhog
ports:
- "6001:8025"
mongoexpress:
image: mongo-express
environment:
ME_CONFIG_MONGODB_SERVER: mongo
ports:
- "6002:8081"
links:
- mongo
depends_on:
- mongo

View File

@ -0,0 +1,5 @@
Welcome to OhMyForm
Your Username is {{username}}
cheers

View File

@ -0,0 +1,5 @@
Welcome to OhMyForm
Your Username is {{username}}
cheers

View File

@ -0,0 +1 @@
Welcome to OhMyForm

7
nest-cli.json Normal file
View File

@ -0,0 +1,7 @@
{
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"plugins": ["@nestjs/graphql/plugin"]
}
}

101
package.json Normal file
View File

@ -0,0 +1,101 @@
{
"name": "ohmyform-api",
"version": "0.3.0",
"description": "",
"author": "",
"private": true,
"license": "UNLICENSED",
"scripts": {
"prebuild": "rimraf dist",
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"cli:dev": "cross-env CLI=true TS_NODE_TRANSPILE_ONLY=true ts-node -r tsconfig-paths/register src/cli.ts",
"cli": "cross-env CLI=true node dist/console.js",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "cross-env NODE_ENV=production node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@nestjs-modules/mailer": "^1.4.2",
"@nestjs/common": "^7.0.9",
"@nestjs/config": "^0.4.0",
"@nestjs/core": "^7.0.9",
"@nestjs/graphql": "^7.3.7",
"@nestjs/jwt": "^7.0.0",
"@nestjs/mongoose": "^6.4.0",
"@nestjs/passport": "^7.0.0",
"@nestjs/platform-express": "^7.0.0",
"apollo-server-express": "^2.13.0",
"bcrypt": "^4.0.1",
"class-transformer": "^0.2.3",
"class-validator": "^0.12.2",
"commander": "^5.1.0",
"cors": "^2.8.5",
"cross-env": "^7.0.2",
"graphql": "15.0.0",
"graphql-tools": "^5.0.0",
"handlebars": "^4.7.6",
"inquirer": "^7.1.0",
"migrate-mongoose": "^4.0.0",
"mongoose": "^5.9.11",
"nestjs-console": "^3.0.2",
"nestjs-pino": "^1.2.0",
"nodemailer": "^6.4.6",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"passport-local": "^1.0.0",
"pino-pretty": "^4.0.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rxjs": "^6.5.4"
},
"devDependencies": {
"@nestjs/cli": "^7.0.0",
"@nestjs/schematics": "^7.0.0",
"@nestjs/testing": "^7.0.0",
"@types/bcrypt": "^3.0.0",
"@types/express": "^4.17.3",
"@types/handlebars": "^4.1.0",
"@types/inquirer": "^6.5.0",
"@types/jest": "25.1.4",
"@types/mongoose": "^5.7.14",
"@types/node": "^13.9.1",
"@types/passport-jwt": "^3.0.3",
"@types/passport-local": "^1.0.33",
"@types/supertest": "^2.0.8",
"@typescript-eslint/eslint-plugin": "^2.23.0",
"@typescript-eslint/parser": "^2.23.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.0",
"eslint-plugin-import": "^2.20.1",
"jest": "^25.1.0",
"prettier": "^1.19.1",
"supertest": "^4.0.2",
"ts-jest": "25.2.1",
"ts-loader": "^6.2.1",
"ts-node": "^8.6.2",
"tsconfig-paths": "^3.9.0",
"typescript": "^3.7.4"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

110
src/app.imports.ts Normal file
View File

@ -0,0 +1,110 @@
import { MailerModule } from '@nestjs-modules/mailer';
import { HttpModule, RequestMethod } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { GraphQLFederationModule } from '@nestjs/graphql';
import { JwtModule, JwtModuleOptions } from '@nestjs/jwt';
import { MongooseModule } from '@nestjs/mongoose';
import { MongooseModuleOptions } from '@nestjs/mongoose/dist/interfaces/mongoose-options.interface';
import crypto from 'crypto';
import { ConsoleModule } from 'nestjs-console';
import { LoggerModule, Params as LoggerModuleParams } from 'nestjs-pino/dist';
import { join } from 'path';
import { ContextCache } from './resolver/context.cache';
import { schema } from './schema';
export const LoggerConfig: LoggerModuleParams = {
pinoHttp: {
level: process.env.CLI ? 'warn' : process.env.NODE_ENV !== 'production' ? 'debug' : 'info',
prettyPrint: process.env.NODE_ENV !== 'production' || process.env.CLI ? {
translateTime: true,
colorize: true,
ignore: 'pid,hostname,req,res',
} : false,
},
exclude: [
{
method: RequestMethod.ALL,
path: '_health',
},
{
method: RequestMethod.ALL,
path: 'favicon.ico',
}
],
}
export const imports = [
ConsoleModule,
HttpModule.register({
timeout: 5000,
maxRedirects: 10,
}),
ConfigModule.forRoot({
load: [
() => {
return {
LOCALES_PATH: join(process.cwd(), 'locales'),
AUTH_SECRET: process.env.AUTH_SECRET || crypto.randomBytes(20).toString('hex'),
}
}
],
}),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService): Promise<JwtModuleOptions> => ({
secret: configService.get<string>('AUTH_SECRET'),
signOptions: {
expiresIn: '4h',
},
})
}),
LoggerModule.forRoot(LoggerConfig),
GraphQLFederationModule.forRoot({
debug: process.env.NODE_ENV !== 'production',
definitions: {
outputAs: 'class',
},
introspection: true,
playground: true,
// installSubscriptionHandlers: true,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
// to allow guards on resolver props https://github.com/nestjs/graphql/issues/295
fieldResolverEnhancers: [
'guards',
'interceptors',
],
resolverValidationOptions: {
},
context: ({ req }) => {
return {
cache: new ContextCache(),
req,
}
},
}),
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService): Promise<MongooseModuleOptions> => ({
uri: configService.get<string>('MONGODB_URI', 'mongodb://localhost/ohmyform'),
// takes care of deprecations from https://mongoosejs.com/docs/deprecations.html
useNewUrlParser: true,
useUnifiedTopology: true,
useCreateIndex: true,
useFindAndModify: false,
})
}),
MongooseModule.forFeature(schema),
MailerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => ({
transport: configService.get<string>('MAILER_URI', 'smtp://localhost:1025'),
defaults: {
from: configService.get<string>('MAILER_FROM', 'OhMyForm <no-reply@localhost>'),
},
}),
}),
]

11
src/app.module.ts Normal file
View File

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { imports } from './app.imports';
import { providers } from './app.providers';
import { controllers } from './controller';
@Module({
imports,
controllers,
providers,
})
export class AppModule {}

11
src/app.providers.ts Normal file
View File

@ -0,0 +1,11 @@
import { commands } from './command';
import { guards } from './guard';
import { resolvers } from './resolver';
import { services } from './service';
export const providers = [
...resolvers,
...commands,
...services,
...guards,
]

16
src/cli.ts Normal file
View File

@ -0,0 +1,16 @@
import { BootstrapConsole } from 'nestjs-console';
import { AppModule } from './app.module';
const bootstrap = new BootstrapConsole({
module: AppModule,
useDecorators: true,
});
bootstrap.init().then(async (app) => {
try {
await app.init();
await bootstrap.boot();
process.exit(0);
} catch (e) {
process.exit(1);
}
});

5
src/command/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { UserCommand } from './user.command';
export const commands = [
UserCommand,
]

View File

@ -0,0 +1,64 @@
import inquirer from 'inquirer';
import { Command, Console } from 'nestjs-console';
import { matchType, validatePassword } from '../config/fields';
import { UserCreateService } from '../service/user/user.create.service';
@Console({
name: 'user',
description: 'handle instance users'
})
export class UserCommand {
constructor(
private readonly createUser: UserCreateService
) {
}
@Command({
command: 'create',
})
async create(): Promise<void> {
const answers = await inquirer.prompt([
{
type: 'input',
name: 'username',
message: 'username for login',
},
{
type: 'input',
name: 'email',
message: 'email to send notifications to',
validate(input: string): boolean | string {
if (!matchType.email.test(input)) {
return 'invalid email'
}
return true
},
},
{
type: 'password',
name: 'password',
validate: validatePassword,
message: 'password to login',
},
{
type: 'confirm',
name: 'create',
message: current => {
return `create user ${current.username} with email ${current.email}`
}
}
])
await this.createUser.create(answers)
console.info(`user ${answers.username} has been created`)
}
@Command({
command: 'activate <username>',
})
async activate(username: string): Promise<void> {
console.log(`activate user ${username}`)
}
}

30
src/config/fields.ts Normal file
View File

@ -0,0 +1,30 @@
export const fieldTypes = [
'textfield',
'date',
'email',
'legal',
'textarea',
'link',
'statement',
'dropdown',
'rating',
'radio',
'hidden',
'yes_no',
'number',
]
export const matchType = {
color: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/,
url: /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)/,
email: /.+@.+\..+/,
}
export const validatePassword = (password: string): true | string => {
if (password.length < 4) {
return 'password is too short'
}
return true
}

4
src/config/languages.ts Normal file
View File

@ -0,0 +1,4 @@
export const languages = ['en', 'fr', 'es', 'it', 'de']
export const defaultLanguage = 'en'

5
src/config/roles.ts Normal file
View File

@ -0,0 +1,5 @@
export type roleType = 'user' | 'admin' | 'superuser'
export type rolesType = roleType[]
export const roles: rolesType = ['user', 'admin', 'superuser']

View File

@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller()
export class HealthController {
@Get('/_health')
getHello(): string {
return 'ok';
}
}

5
src/controller/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { HealthController } from './health.controller';
export const controllers = [
HealthController,
]

View File

@ -0,0 +1,4 @@
import { SetMetadata } from '@nestjs/common';
import { rolesType } from '../config/roles';
export const Roles = (...roles: rolesType) => SetMetadata('roles', roles);

View File

@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const User = createParamDecorator(
(data: unknown, ctx: ExecutionContext) =>
GqlExecutionContext.create(ctx).getContext().req.user,
);

View File

@ -0,0 +1,15 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('AuthToken')
export class AuthJwtModel {
@Field()
readonly accessToken: string
@Field()
readonly refreshToken: string
constructor(partial: Partial<AuthJwtModel>) {
this.accessToken = partial.accessToken
this.refreshToken = partial.refreshToken
}
}

View File

@ -0,0 +1,20 @@
import { Field, InterfaceType } from '@nestjs/graphql';
import { Notifications } from '../../schema/form.schema';
@InterfaceType('Notification')
export class AbstractNotificationModel {
@Field({ nullable: true })
readonly subject?: string
@Field({ nullable: true })
readonly htmlTemplate?: string
@Field()
readonly enabled: boolean
constructor(partial: Partial<Notifications>) {
this.subject = partial.subject
this.htmlTemplate = partial.htmlTemplate
this.enabled = partial.enabled
}
}

View File

@ -0,0 +1,27 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('Button')
export class ButtonModel {
@Field({ nullable: true })
readonly url?: string
@Field({ nullable: true })
readonly action?: string
@Field({ nullable: true })
readonly text: string
@Field({ nullable: true })
readonly bgColor?: string
@Field({ nullable: true })
readonly color?: string
constructor(button: Partial<ButtonModel>) {
this.url = button.url
this.action = button.action
this.text = button.text
this.bgColor = button.bgColor
this.color = button.color
}
}

View File

@ -0,0 +1,28 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Colors } from '../../schema/form.schema';
@ObjectType('Colors')
export class ColorsModel {
@Field()
readonly backgroundColor: string
@Field()
readonly questionColor: string
@Field()
readonly answerColor: string
@Field()
readonly buttonColor: string
@Field()
readonly buttonTextColor: string
constructor(partial: Partial<Colors>) {
this.backgroundColor = partial.backgroundColor
this.questionColor = partial.questionColor
this.answerColor = partial.answerColor
this.buttonColor = partial.buttonColor
this.buttonTextColor = partial.buttonTextColor
}
}

View File

@ -0,0 +1,17 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Design } from '../../schema/form.schema';
import { ColorsModel } from './colors.model';
@ObjectType('Design')
export class DesignModel {
@Field()
readonly colors: ColorsModel
@Field({ nullable: true })
readonly font?: string
constructor(partial: Partial<Design>) {
this.colors = new ColorsModel(partial.colors)
this.font = partial.font
}
}

View File

@ -0,0 +1,32 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { FormDocument } from '../../schema/form.schema';
@ObjectType('Form')
export class FormModel {
@Field(() => ID)
readonly id: string
@Field()
readonly title: string
@Field()
readonly created: Date
@Field({ nullable: true })
readonly lastModified?: Date
@Field()
readonly language: string
@Field()
readonly showFooter: boolean
constructor(partial: Partial<FormDocument>) {
this.id = partial.id
this.title = partial.title
this.created = partial.created
this.lastModified = partial.lastModified
this.language = partial.language
this.showFooter = partial.showFooter
}
}

View File

@ -0,0 +1,29 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { FormPage } from '../../schema/form.schema';
import { ButtonModel } from './button.model';
@ObjectType('FormPage')
export class FormPageModel {
@Field()
readonly show: boolean
@Field({ nullable: true })
readonly title?: string
@Field({ nullable: true })
readonly paragraph?: string
@Field({ nullable: true })
readonly buttonText?: string
@Field(() => [ButtonModel])
readonly buttons: ButtonModel[]
constructor(page: Partial<FormPage>) {
this.show = page.show
this.title = page.title
this.paragraph = page.paragraph
this.buttonText = page.buttonText
this.buttons = page.buttons.map(button => new ButtonModel(button))
}
}

View File

@ -0,0 +1,21 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { RespondentNotifications } from '../../schema/form.schema';
import { AbstractNotificationModel } from './abstract.notification.model';
@ObjectType({
implements: [AbstractNotificationModel],
})
export class RespondentNotificationsModel extends AbstractNotificationModel {
@Field({ nullable: true })
readonly toField?: string
@Field({ nullable: true })
readonly fromEmail?: string
constructor(partial: Partial<RespondentNotifications>) {
super(partial);
this.toField = partial.toField
this.fromEmail = partial.fromEmail
}
}

View File

@ -0,0 +1,21 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { SelfNotifications } from '../../schema/form.schema';
import { AbstractNotificationModel } from './abstract.notification.model';
@ObjectType({
implements: [AbstractNotificationModel],
})
export class SelfNotificationsModel extends AbstractNotificationModel {
@Field({ nullable: true })
readonly fromField?: string
@Field({ nullable: true })
readonly toEmail?: string
constructor(partial: Partial<SelfNotifications>) {
super(partial);
this.fromField = partial.fromField
this.toEmail = partial.toEmail
}
}

11
src/dto/status.model.ts Normal file
View File

@ -0,0 +1,11 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('Version')
export class StatusModel {
@Field()
readonly version: string
constructor(partial: Partial<StatusModel>) {
this.version = partial.version
}
}

View File

@ -0,0 +1,8 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UserModel } from './user.model';
@ObjectType('OwnUser')
export class OwnUserModel extends UserModel {
@Field(() => [String])
readonly roles: string[]
}

View File

@ -0,0 +1,28 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsEmail, IsNotEmpty, MaxLength, MinLength } from 'class-validator';
@InputType()
export class UserCreateInput {
@Field()
@MinLength(2)
@MaxLength(50)
username: string
@Field()
@IsEmail()
@IsNotEmpty()
email: string
@Field()
@MinLength(5)
password: string
@Field({ nullable: true })
firstName?: string
@Field({ nullable: true })
lastName?: string
@Field({ nullable: true })
language?: string
}

View File

@ -0,0 +1,33 @@
import { Field, ID, ObjectType } from '@nestjs/graphql';
import { UserDocument } from '../../schema/user.schema';
@ObjectType('User')
export class UserModel {
@Field(() => ID)
readonly id: string
@Field()
readonly username: string
@Field()
readonly email: string
@Field()
readonly language: string
@Field()
readonly firstName?: string
@Field()
readonly lastName?: string
constructor(user: Partial<UserDocument>) {
this.id = user.id
this.username = user.username
this.email = user.email
this.language = user.language
this.firstName = user.firstName
this.lastName = user.lastName
}
}

View File

@ -0,0 +1,23 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class GqlAuthGuard extends AuthGuard('jwt') {
getRequest(context: ExecutionContext) {
const ctx = GqlExecutionContext.create(context);
if (!ctx.getContext().cache) {
ctx.getContext().cache = {
// add(type, id, object) =>
}
}
return ctx.getContext().req;
}
handleRequest(err, user) {
if (err) {
throw new Error('invalid token')
}
return user
}
}

16
src/guard/index.ts Normal file
View File

@ -0,0 +1,16 @@
import { APP_GUARD } from '@nestjs/core';
import { GqlAuthGuard } from './gql.auth.guard';
import { LocalAuthGuard } from './local.auth.guard';
import { RolesGuard } from './roles.guard';
export const guards = [
{
provide: APP_GUARD,
useClass: GqlAuthGuard,
},
{
provide: APP_GUARD,
useClass: RolesGuard,
},
LocalAuthGuard,
]

View File

@ -0,0 +1,10 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
getRequest(context: ExecutionContext) {
return GqlExecutionContext.create(context).getContext().req
}
}

27
src/guard/roles.guard.ts Normal file
View File

@ -0,0 +1,27 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const ctx = GqlExecutionContext.create(context);
const roles = this.reflector.get<string[]>('roles', ctx.getHandler());
if (!roles) {
return true;
}
const userRoles = ctx.getContext().req.user ? ctx.getContext().req.user.roles : []
for (const role of roles) {
if (!userRoles.includes(role)) {
return false;
}
}
return true;
}
}

23
src/main.ts Normal file
View File

@ -0,0 +1,23 @@
import { NestApplicationOptions, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import cors from 'cors';
import { Logger, PinoLogger } from 'nestjs-pino/dist';
import { LoggerConfig } from './app.imports';
import { AppModule } from './app.module';
(async () => {
const options: NestApplicationOptions = {
logger: new Logger(new PinoLogger(LoggerConfig), {}),
}
const app = await NestFactory.create(AppModule, options)
app.useLogger(app.get(Logger))
app.useGlobalPipes(new ValidationPipe({
disableErrorMessages: false,
transform: true,
}))
app.enableCors({origin: '*'})
app.getHttpAdapter().options('*', cors())
await app.listen(process.env.PORT || 3000);
})()

View File

@ -0,0 +1,26 @@
import { Injectable } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { AuthJwtModel } from '../../dto/auth/auth.jwt.model';
import { AuthService } from '../../service/auth/auth.service';
@Injectable()
export class AuthLoginResolver {
constructor(
private readonly auth: AuthService
) {
}
@Mutation(() => AuthJwtModel)
async authLogin(
@Args({ name: 'username', type: () => String }) username,
@Args({ name: 'password', type: () => String }) password,
): Promise<AuthJwtModel> {
const user = await this.auth.validateUser(username, password)
if (!user) {
throw new Error('invalid user / password')
}
return this.auth.login(user)
}
}

View File

@ -0,0 +1,24 @@
import { Injectable } from '@nestjs/common';
import { Args, Mutation } from '@nestjs/graphql';
import { AuthJwtModel } from '../../dto/auth/auth.jwt.model';
import { UserCreateInput } from '../../dto/user/user.create.input';
import { AuthService } from '../../service/auth/auth.service';
import { UserCreateService } from '../../service/user/user.create.service';
@Injectable()
export class AuthRegisterResolver {
constructor(
private readonly createUser: UserCreateService,
private readonly auth: AuthService,
) {
}
@Mutation(() => AuthJwtModel)
async authRegister(
@Args({ name: 'user' }) data: UserCreateInput,
): Promise<AuthJwtModel> {
const user = await this.createUser.create(data)
return this.auth.login(user)
}
}

View File

@ -0,0 +1,7 @@
import { AuthLoginResolver } from './auth.login.resolver';
import { AuthRegisterResolver } from './auth.register.resolver';
export const authServices = [
AuthRegisterResolver,
AuthLoginResolver,
]

View File

@ -0,0 +1,28 @@
import { FormDocument } from '../schema/form.schema';
import { UserDocument } from '../schema/user.schema';
export class ContextCache {
private users: {
[id: string]: UserDocument,
} = {}
private forms: {
[id: string]: FormDocument,
} = {}
public addUser(user: UserDocument) {
this.users[user.id] = user;
}
public async getUser(id: any): Promise<UserDocument> {
return this.users[id]
}
public addForm(form: FormDocument) {
this.forms[form.id] = form
}
public async getForm(id: any): Promise<FormDocument> {
return this.forms[id]
}
}

View File

@ -0,0 +1,132 @@
import { Args, Context, ID, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { Roles } from '../../decorator/roles.decorator';
import { User } from '../../decorator/user.decorator';
import { DesignModel } from '../../dto/form/design.model';
import { FormModel } from '../../dto/form/form.model';
import { FormPageModel } from '../../dto/form/form.page.model';
import { RespondentNotificationsModel } from '../../dto/form/respondent.notifications.model';
import { SelfNotificationsModel } from '../../dto/form/self.notifications.model';
import { UserModel } from '../../dto/user/user.model';
import { UserDocument } from '../../schema/user.schema';
import { FormService } from '../../service/form/form.service';
import { ContextCache } from '../context.cache';
@Resolver(() => FormModel)
export class FormResolver {
constructor(
private readonly formService: FormService,
) {
}
@Query(() => FormModel)
async getFormById(
@User() user: UserDocument,
@Args('id', {type: () => ID}) id,
@Context('cache') cache: ContextCache,
) {
const form = await this.formService.findById(id)
if (!form.isLive && !await this.formService.isAdmin(form, user)) {
throw new Error('invalid form')
}
cache.addForm(form)
return new FormModel(form)
}
@ResolveField('isLive', () => Boolean)
@Roles('admin')
async getRoles(
@User() user: UserDocument,
@Parent() parent: FormModel,
@Context('cache') cache: ContextCache,
): Promise<boolean> {
const form = await cache.getForm(parent.id)
if (!await this.formService.isAdmin(form, user)) {
throw new Error('no access to field')
}
return form.isLive
}
@ResolveField('selfNotifications', () => SelfNotificationsModel)
@Roles('admin')
async getSelfNotifications(
@User() user: UserDocument,
@Parent() parent: FormModel,
@Context('cache') cache: ContextCache,
): Promise<SelfNotificationsModel> {
const form = await cache.getForm(parent.id)
if (!await this.formService.isAdmin(form, user)) {
throw new Error('no access to field')
}
return new SelfNotificationsModel(form.selfNotifications)
}
@ResolveField('respondentNotifications', () => SelfNotificationsModel)
@Roles('admin')
async getRespondentNotifications(
@User() user: UserDocument,
@Parent() parent: FormModel,
@Context('cache') cache: ContextCache,
): Promise<SelfNotificationsModel> {
const form = await cache.getForm(parent.id)
if (!await this.formService.isAdmin(form, user)) {
throw new Error('no access to field')
}
return new RespondentNotificationsModel(form.respondentNotifications)
}
@ResolveField('design', () => DesignModel)
async getDesign(
@User() user: UserDocument,
@Parent() parent: FormModel,
@Context('cache') cache: ContextCache,
): Promise<DesignModel> {
const form = await cache.getForm(parent.id)
return new DesignModel(form.design)
}
@ResolveField('startPage', () => FormPageModel)
async getStartPage(
@Parent() parent: FormModel,
@Context('cache') cache: ContextCache,
): Promise<FormPageModel> {
const form = await cache.getForm(parent.id)
return new FormPageModel(form.startPage)
}
@ResolveField('endPage', () => FormPageModel)
async getEndPage(
@Parent() parent: FormModel,
@Context('cache') cache: ContextCache,
): Promise<FormPageModel> {
const form = await cache.getForm(parent.id)
return new FormPageModel(form.endPage)
}
@ResolveField('admin', () => UserModel)
@Roles('superuser')
async getAdmin(
@Parent() parent: FormModel,
@Context('cache') cache: ContextCache,
): Promise<UserModel> {
const form = await cache.getForm(parent.id)
if (!form.populated('admin')) {
form.populate('admin')
await form.execPopulate()
}
return new UserModel(form.admin)
}
}

View File

@ -0,0 +1,9 @@
import { FieldResolver } from './field.resolver';
import { FormCreateResolver } from './form.create.resolver';
import { FormResolver } from './form.resolver';
export const formResolvers = [
FormResolver,
FormCreateResolver,
FieldResolver,
]

13
src/resolver/index.ts Normal file
View File

@ -0,0 +1,13 @@
import { authServices } from './auth';
import { formResolvers } from './form';
import { myResolvers } from './me';
import { StatusResolver } from './status.resolver';
import { userResolvers } from './user';
export const resolvers = [
StatusResolver,
...userResolvers,
...authServices,
...myResolvers,
...formResolvers,
]

5
src/resolver/me/index.ts Normal file
View File

@ -0,0 +1,5 @@
import { ProfileResolver } from './profile.resolver';
export const myResolvers = [
ProfileResolver,
]

View File

@ -0,0 +1,19 @@
import { Context, Query } from '@nestjs/graphql';
import { Roles } from '../../decorator/roles.decorator';
import { User } from '../../decorator/user.decorator';
import { OwnUserModel } from '../../dto/user/own.user.model';
import { UserDocument } from '../../schema/user.schema';
import { ContextCache } from '../context.cache';
export class ProfileResolver {
@Query(() => OwnUserModel)
@Roles('user')
async me(
@User() user: UserDocument,
@Context('cache') cache: ContextCache,
): Promise<OwnUserModel> {
cache.addUser(user)
return new OwnUserModel(user)
}
}

View File

@ -0,0 +1,12 @@
import { Query, Resolver } from '@nestjs/graphql';
import { StatusModel } from '../dto/status.model';
@Resolver(() => StatusModel)
export class StatusResolver {
@Query(() => StatusModel)
async status(): Promise<StatusModel> {
return new StatusModel({
version: process.env.npm_package_version,
})
}
}

View File

@ -0,0 +1,5 @@
import { UserResolver } from './user.resolver';
export const userResolvers = [
UserResolver,
]

View File

@ -0,0 +1,36 @@
import { Args, Context, GraphQLExecutionContext, ID, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql';
import { rolesType } from '../../config/roles';
import { Roles } from '../../decorator/roles.decorator';
import { UserModel } from '../../dto/user/user.model';
import { UserService } from '../../service/user/user.service';
import { ContextCache } from '../context.cache';
@Resolver(() => UserModel)
export class UserResolver {
constructor(
private readonly userService: UserService,
) {
}
@Query(() => UserModel)
@Roles('admin')
async getUserById(
@Args('id', {type: () => ID}) id,
@Context('cache') cache: ContextCache,
) {
const user = await this.userService.findById(id)
cache.addUser(user)
return new UserModel(user)
}
@ResolveField('roles', () => [String])
@Roles('superuser')
async getRoles(
@Parent() user: UserModel,
@Context('cache') cache: ContextCache,
): Promise<string[]> {
return (await cache.getUser(user.id)).roles
}
}

View File

@ -0,0 +1,33 @@
import { Schema, Document } from 'mongoose';
import { matchType } from '../config/fields';
export interface ButtonDocument extends Document{
readonly url?: string
readonly action?: string
readonly text?: string
readonly bgColor?: string
readonly color?: string
}
export const ButtonSchema = new Schema({
url: {
type: String,
match: matchType.url,
},
action: {
type: String,
},
text: {
type: String,
},
bgColor: {
type: String,
match: matchType.color,
default: '#5bc0de',
},
color: {
type: String,
match: matchType.color,
default: '#ffffff'
},
})

View File

@ -0,0 +1,17 @@
import { SchemaDefinition } from 'mongoose';
export const FieldOption: SchemaDefinition = {
id: {
alias: 'option_id',
type: Number,
},
title: {
alias: 'option_title',
type: String,
},
value: {
alias: 'option_value',
type: String,
trim: true,
},
}

View File

@ -0,0 +1,37 @@
import { Schema, SchemaDefinition } from 'mongoose';
import { FieldSchemaName } from '../field.schema';
export const LogicJump: SchemaDefinition = {
expressionString: {
type: String,
enum: [
'field == static',
'field != static',
'field > static',
'field >= static',
'field <= static',
'field < static',
'field contains static',
'field !contains static',
'field begins static',
'field !begins static',
'field ends static',
'field !ends static',
],
},
fieldA: {
type: Schema.Types.ObjectId,
ref: FieldSchemaName
},
valueB: {
type: String,
},
jumpTo: {
type: Schema.Types.ObjectId,
ref: FieldSchemaName
},
enabled: {
type: Boolean,
default: false,
},
}

View File

@ -0,0 +1,32 @@
import { SchemaDefinition } from 'mongoose';
export const RatingField: SchemaDefinition = {
steps: {
type: Number,
min: 1,
max: 10,
},
shape: {
type: String,
enum: [
'Heart',
'Star',
'thumbs-up',
'thumbs-down',
'Circle',
'Square',
'Check Circle',
'Smile Outlined',
'Hourglass',
'bell',
'Paper Plane',
'Comment',
'Trash',
],
},
validShapes: {
type: [{
type: String,
}]
}
}

View File

@ -0,0 +1,58 @@
import { Document, Schema } from 'mongoose';
import { fieldTypes } from '../config/fields';
import { FieldOption } from './embedded/field.option';
import { LogicJump } from './embedded/logic.jump';
import { RatingField } from './embedded/rating.field';
export const FieldSchemaName = 'FormField'
export interface FieldDocument extends Document {
isSubmission: boolean
}
export const FieldSchema = new Schema({
isSubmission: {
type: Boolean,
default: false,
},
title: {
type: String,
trim: true,
},
description: {
type: String,
default: '',
},
logicJump: {
type: LogicJump,
},
ratingOptions: {
type: RatingField,
},
fieldOptions: {
type: [FieldOption],
},
required: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
deletePreserved: { // TODO remove
type: Boolean,
default: false,
},
validFieldTypes: { // TODO remove
type: [String],
},
fieldType: {
type: String,
enum: fieldTypes,
},
fieldValue: {
type: Schema.Types.Mixed,
default: '',
},
})

249
src/schema/form.schema.ts Normal file
View File

@ -0,0 +1,249 @@
import exp from 'constants';
import { Document, Schema } from 'mongoose';
import { matchType } from '../config/fields';
import { defaultLanguage, languages } from '../config/languages';
import { rolesType } from '../config/roles';
import { ButtonDocument, ButtonSchema } from './button.schema';
import { FieldDocument, FieldSchema } from './field.schema';
import { VisitorDataDocument, VisitorDataSchema } from './visitor.data.schema';
import { UserDocument, UserSchemaName } from './user.schema';
export const FormSchemaName = 'Form'
export interface FormPage {
readonly show: boolean
readonly title?: string
readonly paragraph?: string
readonly buttonText?: string
readonly buttons: [ButtonDocument]
}
export interface Notifications {
readonly subject?: string
readonly htmlTemplate?: string
readonly enabled: boolean
}
export interface SelfNotifications extends Notifications{
readonly fromField?: string
readonly toEmail?: string
}
export interface RespondentNotifications extends Notifications{
readonly toField?: string
readonly fromEmail?: string
}
export interface Colors {
readonly backgroundColor: string
readonly questionColor: string
readonly answerColor: string
readonly buttonColor: string
readonly buttonTextColor: string
}
export interface Design {
readonly colors: Colors
readonly font?: string
}
export interface FormDocument extends Document {
readonly title: string
readonly language: string
readonly analytics: {
readonly gaCode?: string
// TODO extract to separate documents!
readonly visitors: [VisitorDataDocument]
}
readonly fields: [FieldDocument]
readonly admin: UserDocument
readonly startPage: FormPage;
readonly endPage: FormPage;
readonly selfNotifications: SelfNotifications;
readonly respondentNotifications: RespondentNotifications;
readonly showFooter: boolean;
readonly isLive: boolean;
readonly design: Design;
readonly created: Date
readonly lastModified: Date
}
export const FormSchema = new Schema({
title: {
trim: true,
type: String,
required: true,
},
created: {
type: Date,
},
lastModified: {
type: Date,
},
language: {
type: String,
enum: languages,
default: defaultLanguage,
required: true,
},
analytics: {
gaCode: {
type: String,
},
visitors: {
type: [VisitorDataSchema],
},
},
fields: {
alias: 'form_fields',
type: [FieldSchema],
default: [],
},
admin: {
type: Schema.Types.ObjectId,
ref: UserSchemaName,
},
startPage: {
show: {
alias: 'showStart',
type: Boolean,
default: false
},
title: {
alias: 'introTitle',
type: String,
default: 'Welcome to Form',
},
paragraph: {
alias: 'introParagraph',
type: String,
default: 'Start',
},
buttons: {
type: [ButtonSchema],
},
},
endPage: {
show: {
alias: 'showEnd',
type: Boolean,
default: false,
},
title: {
type: String,
default: 'Thank you for filling out the form',
},
paragraph: {
type: String,
},
buttonText: {
type: String,
default: 'Go back to Form'
},
buttons: {
type: [ButtonSchema],
},
},
selfNotifications: {
fromField: {
type: String,
},
toEmail: {
alias: 'toEmails',
type: String,
},
subject: {
type: String,
},
htmlTemplate: {
type: String,
},
enabled: {
type: Boolean,
default: false
},
},
respondentNotifications: {
toField: {
type: String,
},
fromEmail: {
alias: 'fromEmails',
type: String,
match: matchType.email,
},
subject: {
type: String,
default: 'OhMyForm: Thank you for filling out this OhMyForm',
},
htmlTemplate: {
type: String,
default: 'Hello, <br><br> Weve received your submission. <br><br> Thank you & have a nice day!',
},
enabled: {
type: Boolean,
default: false,
},
},
showFooter: {
type: Boolean,
default: true,
},
isLive: {
type: Boolean,
default: true,
},
design: {
colors: {
backgroundColor: {
type: String,
match: matchType.color,
default: '#fff'
},
questionColor: {
type: String,
match: matchType.color,
default: '#333'
},
answerColor: {
type: String,
match: matchType.color,
default: '#333'
},
buttonColor: {
type: String,
match: matchType.color,
default: '#fff'
},
buttonTextColor: {
type: String,
match: matchType.color,
default: '#333'
},
},
font: {
type: String,
},
},
}, {
timestamps: {
createdAt: 'created',
updatedAt: 'lastModified',
}
})
export const FormDefinition = {
name: FormSchemaName,
schema: FormSchema,
}

View File

@ -0,0 +1,57 @@
import { Document, Schema } from 'mongoose';
import { FieldSchema } from './field.schema';
import { FormSchemaName } from './form.schema';
export const FormSubmissionSchemaName = 'FormSubmission'
export interface FormSubmissionDocument extends Document {
}
export const FormSubmissionSchema = new Schema({
fields: {
alias: 'form_fields',
type: [FieldSchema],
default: [],
},
form: {
type: Schema.Types.ObjectId,
ref: FormSchemaName,
required: true
},
ipAddr: {
type: String
},
geoLocation: {
Country: {
type: String
},
City: {
type: String
}
},
device: {
type: {
type: String
},
name: {
type: String
}
},
timeElapsed: {
type: Number
},
percentageComplete: {
type: Number
},
}, {
timestamps: {
createdAt: 'created',
updatedAt: 'lastModified',
}
})
export const FormSubmissionDefinition = {
name: FormSubmissionSchemaName,
schema: FormSubmissionSchema,
}

9
src/schema/index.ts Normal file
View File

@ -0,0 +1,9 @@
import { FormDefinition } from './form.schema';
import { FormSubmissionDefinition, FormSubmissionSchema } from './form.submission.schema';
import { UserDefinition } from './user.schema';
export const schema = [
FormDefinition,
FormSubmissionDefinition,
UserDefinition,
]

99
src/schema/user.schema.ts Normal file
View File

@ -0,0 +1,99 @@
import { Document, Schema } from 'mongoose';
import { defaultLanguage, languages } from '../config/languages';
import { roles, rolesType } from '../config/roles';
export const UserSchemaName = 'User'
export interface UserDocument extends Document {
readonly firstName?: string
readonly lastName?: string
readonly email: string
readonly username: string
readonly passwordHash: string
readonly salt: string
readonly provider: string
readonly roles: rolesType
readonly language: string
readonly resetPasswordToken?: string
readonly resetPasswordExpires?: Date
readonly token?: string
readonly apiKey?: string
readonly created: Date
readonly lastModified: Date
}
export const UserSchema = new Schema({
firstName: {
type: String,
trim: true,
default: '',
},
lastName: {
type: String,
trim: true,
default: '',
},
email: {
type: String,
trim: true,
lowercase: true,
unique: true,
match: /^(([^<>()\[\]\\.,;:\s@']+(\.[^<>()\[\]\\.,;:\s@']+)*)|('.+'))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
required: true,
},
username: {
type: String,
unique: true,
lowercase: true,
match: /^[a-zA-Z0-9\-]+$/,
required: true,
},
passwordHash: {
type: String,
default: '',
},
salt: {
type: String,
},
provider: {
type: String,
default: 'local'
},
roles: {
type: [{
type: String,
enum: roles,
}],
default: ['user'],
},
language: {
type: String,
enum: languages,
default: defaultLanguage,
},
resetPasswordToken: {
type: String,
},
resetPasswordExpires: {
type: Date,
},
token: {
type: String,
},
apiKey: {
type: String,
unique: true,
index: true,
sparse: true
},
}, {
timestamps: {
createdAt: 'created',
updatedAt: 'lastModified',
}
})
export const UserDefinition = {
name: UserSchemaName,
schema: UserSchema,
}

View File

@ -0,0 +1,52 @@
import { Document, Schema } from 'mongoose';
import { defaultLanguage, languages } from '../config/languages';
import { FieldDocument, FieldSchemaName } from './field.schema';
export interface VisitorDataDocument extends Document {
readonly introParagraph?: string
readonly referrer?: string
readonly filledOutFields: [FieldDocument]
readonly timeElapsed: number
readonly isSubmitted: boolean
readonly language: string
readonly ipAddr: string
readonly deviceType: string
readonly userAgent: string
}
export const VisitorDataSchema = new Schema({
introParagraph: {
type: String,
},
referrer: {
type: String,
},
filledOutFields: {
type: [{
type: Schema.Types.ObjectId,
ref: FieldSchemaName,
}],
},
timeElapsed: {
type: Number,
},
isSubmitted: {
type: Boolean,
},
language: {
type: String,
enum: languages,
default: defaultLanguage,
},
ipAddr: {
type: String,
},
deviceType: {
type: String,
enum: ['desktop', 'phone', 'tablet', 'other'],
default: 'other',
},
userAgent: {
type: String,
},
})

View File

@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthJwtModel } from '../../dto/auth/auth.jwt.model';
import { UserDocument } from '../../schema/user.schema';
import { UserService } from '../user/user.service';
import { PasswordService } from './password.service';
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
private passwordService: PasswordService,
) {}
async validateUser(username: string, password: string): Promise<UserDocument> {
console.log('check user??', username)
const user = await this.userService.findByUsername(username);
if (user && await this.passwordService.verify(password, user.passwordHash, user.salt)) {
return user;
}
return null;
}
async login(user: UserDocument): Promise<AuthJwtModel> {
return new AuthJwtModel({
accessToken: this.jwtService.sign({
username: user.username,
roles: user.roles,
sub: user.id,
}),
refreshToken: this.jwtService.sign({
sub: user.id,
}, {
expiresIn: '30d',
}),
});
}
}

11
src/service/auth/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { LocalStrategy } from './local.strategy';
import { PasswordService } from './password.service';
export const authServices = [
AuthService,
LocalStrategy,
PasswordService,
JwtStrategy,
]

View File

@ -0,0 +1,30 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserDocument } from '../../schema/user.schema';
import { UserService } from '../user/user.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private readonly userService: UserService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('AUTH_SECRET'),
});
}
async validate(payload: any): Promise<UserDocument> {
try {
return await this.userService.findById(payload.sub)
} catch (e) {
// log error
}
return null
}
}

View File

@ -0,0 +1,19 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { AuthService } from './auth.service';
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<any> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}

View File

@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import * as crypto from 'crypto';
@Injectable()
export class PasswordService {
async verify (password: string, hash: string, salt?: string): Promise<boolean> {
if (hash[0] === '$') {
return await bcrypt.compare(password, hash)
}
//Generate salt if it doesn't exist yet
if(!salt){
salt = crypto.randomBytes(64).toString('base64');
}
return hash === crypto.pbkdf2Sync(
password,
new Buffer(salt, 'base64'),
10000,
128,
'SHA1'
).toString('base64');
}
hash (password): Promise<string> {
return bcrypt.hash(password, 4)
}
}

View File

@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from "mongoose";
import { FormDocument, FormSchemaName } from '../../schema/form.schema';
@Injectable()
export class FormCreateService {
constructor(
@InjectModel(FormSchemaName) private formModel: Model<FormDocument>,
) {
}
}

View File

@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { GraphQLError } from 'graphql';
import { Model, Types } from 'mongoose';
import { FormDocument, FormSchemaName } from '../../schema/form.schema';
import { UserDocument } from '../../schema/user.schema';
@Injectable()
export class FormService {
constructor(
@InjectModel(FormSchemaName) private formModel: Model<FormDocument>,
) {
}
async isAdmin(form: FormDocument, user: UserDocument): Promise<boolean> {
if (user.roles.includes('superuser')) {
return true
}
return Types.ObjectId(form.admin.id).equals(Types.ObjectId(user.id))
}
async findById(id: string): Promise<FormDocument> {
const form = await this.formModel.findById(id);
if (!form) {
throw new Error('no form found')
}
return form
}
}

View File

@ -0,0 +1,7 @@
import { FormCreateService } from './form.create.service';
import { FormService } from './form.service';
export const formServices = [
FormService,
FormCreateService,
]

11
src/service/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { authServices } from './auth';
import { formServices } from './form';
import { MailService } from './mail.service';
import { userServices } from './user';
export const services = [
...userServices,
...formServices,
...authServices,
MailService,
]

View File

@ -0,0 +1,65 @@
import { MailerService } from '@nestjs-modules/mailer';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import fs from 'fs';
import handlebars from 'handlebars';
import { PinoLogger } from 'nestjs-pino/dist';
import { join } from 'path';
import { defaultLanguage } from '../config/languages';
@Injectable()
export class MailService {
constructor(
private readonly nestMailer: MailerService,
private readonly configService: ConfigService,
private readonly logger: PinoLogger,
) {
logger.setContext(this.constructor.name)
}
async send(to: string, template: string, context: { [key: string]: any }, language: string = defaultLanguage): Promise<boolean> {
this.logger.info({
email: to,
}, `send email ${template}`)
try {
const path = this.getTemplatePath(template, language)
const process = (file: string) => {
const content = fs.readFileSync(join(path, file))
const template = handlebars.compile(content.toString('UTF-8'))
return template(context)
}
await this.nestMailer.sendMail({
to,
subject: process('subject.txt'),
html: process('body.html'),
text: process('body.txt'),
})
this.logger.info('sent email')
} catch (error) {
this.logger.error({
error: error.message,
email: to,
}, `failed to send email ${template}`)
return false
}
return true
}
private getTemplatePath(template: string, language: string): string {
let templatePath = join(this.configService.get<string>('LOCALES_PATH'), language, 'mail', template)
if (!fs.existsSync(templatePath)) {
templatePath = join(this.configService.get<string>('LOCALES_PATH'), 'en', 'mail', template)
}
if (!fs.existsSync(templatePath)) {
throw new Error('invalid template')
}
return templatePath
}
}

View File

@ -0,0 +1,7 @@
import { UserCreateService } from './user.create.service';
import { UserService } from './user.service';
export const userServices = [
UserCreateService,
UserService,
]

View File

@ -0,0 +1,47 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { PinoLogger } from 'nestjs-pino/dist';
import { UserCreateInput } from '../../dto/user/user.create.input';
import { UserDocument, UserSchemaName } from '../../schema/user.schema';
import { PasswordService } from '../auth/password.service';
import { MailService } from '../mail.service';
@Injectable()
export class UserCreateService {
constructor(
@InjectModel(UserSchemaName) private userModel: Model<UserDocument>,
private readonly mailerService: MailService,
private readonly logger: PinoLogger,
private readonly passwordService: PasswordService,
) {}
async create(user: UserCreateInput): Promise<UserDocument> {
// TODO check for uniqueness of email & username!
const entry = new this.userModel({
...user,
passwordHash: await this.passwordService.hash(user.password),
})
await entry.save({
validateBeforeSave: true,
})
const sent = await this.mailerService.send(
entry.email,
'user/created',
{
username: entry.username,
confirm: 'https://www.google.com', // TODO confirm url
}
)
// so send email
if (!sent) {
this.logger.warn('failed to send email for user creation')
}
return entry
}
}

View File

@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { GraphQLError } from 'graphql';
import { Model } from 'mongoose';
import { UserDocument, UserSchemaName } from '../../schema/user.schema';
@Injectable()
export class UserService {
constructor(
@InjectModel(UserSchemaName) private userModel: Model<UserDocument>,
) {
}
async findById(id: string): Promise<UserDocument> {
const user = await this.userModel.findById(id);
if (!user) {
throw new Error('no user found')
}
return user
}
async findByUsername(username: string): Promise<UserDocument> {
const user = await this.userModel.findOne({
username,
})
if (!user) {
throw new Error('no user found')
}
return user
}
}

4
tsconfig.build.json Normal file
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"target": "es2017",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"allowSyntheticDefaultImports": true
},
"include": ["src/**/*"],
"exclude": [
"node_modules",
"dist"
]
}

10446
yarn.lock Normal file

File diff suppressed because it is too large Load Diff