diff --git a/package.json b/package.json index fdd4e7f..d81f9ee 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@nestjs/mongoose": "^11.0.3", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^8.0.0", + "@nestjs/terminus": "10.0.1", + "@nestjs/throttler": "^6.4.0", "@types/bcrypt": "^5.0.2", "@types/passport-jwt": "^4.0.1", "apollo-server-express": "^3.13.0", @@ -43,6 +45,7 @@ "graphql": "^16.11.0", "graphql-subscriptions": "^3.0.0", "graphql-upload-ts": "^2.1.2", + "helmet": "^8.1.0", "husky": "^9.1.7", "moment": "^2.30.1", "mongoose": "^8.15.0", diff --git a/src/app.module.ts b/src/app.module.ts index f60b560..5ccb309 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -2,11 +2,15 @@ import { Module } from '@nestjs/common'; import { GraphQLModule } from '@nestjs/graphql'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; import { MongooseModule } from '@nestjs/mongoose'; +import { ThrottlerModule } from '@nestjs/throttler'; import { PubSub } from 'graphql-subscriptions'; import { join } from 'path'; import * as dotenv from 'dotenv'; +import { APP_GUARD } from '@nestjs/core'; import { UsersModule } from './modules/users/users.module'; +import { HealthModule } from './modules/health/health.module'; +import { GqlThrottlerGuard } from './common/guards/gql-throttler.guard'; const pubSub = new PubSub(); dotenv.config(); @@ -16,20 +20,45 @@ dotenv.config(); GraphQLModule.forRoot({ driver: ApolloDriver, installSubscriptionHandlers: true, - context: ({ req, connection }) => ({ + context: ({ req, res, connection }) => ({ req, + res, connection, pubSub, }), autoSchemaFile: join(process.cwd(), 'src/graphql/schema.gql'), playground: true, formatError: (error) => { - return error - } + return error; + }, }), MongooseModule.forRoot(process.env.MONGO_URI), - UsersModule + ThrottlerModule.forRoot([ + { + name: 'short', + ttl: 1000, + limit: 3, + }, + { + name: 'medium', + ttl: 10000, + limit: 20, + }, + { + name: 'long', + ttl: 60000, + limit: 100, + }, + ]), + UsersModule, + HealthModule, + ], + providers: [ + { + provide: APP_GUARD, + useClass: GqlThrottlerGuard, + }, ], }) -export class AppModule { } +export class AppModule {} diff --git a/src/common/guards/gql-throttler.guard.ts b/src/common/guards/gql-throttler.guard.ts new file mode 100644 index 0000000..cdb30c7 --- /dev/null +++ b/src/common/guards/gql-throttler.guard.ts @@ -0,0 +1,121 @@ +import { ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import { Reflector } from '@nestjs/core'; +import { ThrottlerModuleOptions, ThrottlerStorage } from '@nestjs/throttler'; + +interface RequestWithIP extends Request { + ip?: string; + headers: any; + connection?: { + remoteAddress?: string; + }; + socket?: { + remoteAddress?: string; + }; +} + +@Injectable() +export class GqlThrottlerGuard extends ThrottlerGuard { + private logger = new Logger(GqlThrottlerGuard.name); + + // List of GraphQL fields that should bypass throttling + private skipThrottleFields = [ + 'publicUserStats', + // Add more fields here as needed, for example: + // 'getPublicConfig', + // 'healthCheck', + ]; + + constructor( + options: ThrottlerModuleOptions, + storageService: ThrottlerStorage, + reflector: Reflector, + ) { + super(options, storageService, reflector); + } + + getRequestResponse(context: ExecutionContext) { + const gqlCtx = GqlExecutionContext.create(context); + const ctx = gqlCtx.getContext(); + return { req: ctx.req, res: ctx.res }; + } + + async canActivate(context: ExecutionContext): Promise { + // For non-GraphQL requests, use the parent implementation + if (context.getType() !== 'graphql') { + return super.canActivate(context); + } + + // For GraphQL requests, get the resolver and field name + const gqlContext = GqlExecutionContext.create(context); + const info = gqlContext.getInfo(); + const fieldName = info?.fieldName; + + this.logger.debug(`GraphQL field: ${fieldName}`); + + // Special case: Manually check for fields that should skip throttling + // This is a workaround since the @SkipThrottle decorator metadata isn't being detected + if (this.skipThrottleFields.includes(fieldName)) { + this.logger.debug(`Explicitly skipping throttle for ${fieldName}`); + return true; + } + + // Get the parent class (Resolver) and method (Query/Mutation) + const handler = context.getHandler(); + const classRef = context.getClass(); + + // Check for SkipThrottle at both method and class level + const methodSkipThrottle = this.reflector.get('skipThrottle', handler); + const classSkipThrottle = this.reflector.get('skipThrottle', classRef); + + this.logger.debug( + `Method skipThrottle: ${JSON.stringify(methodSkipThrottle)}`, + ); + this.logger.debug( + `Class skipThrottle: ${JSON.stringify(classSkipThrottle)}`, + ); + + // If method explicitly sets skipThrottle + if (methodSkipThrottle !== undefined) { + const shouldSkip = this.shouldSkipThrottle(methodSkipThrottle); + if (shouldSkip) { + this.logger.debug( + `Skipping throttle for ${fieldName} due to method decorator`, + ); + return true; + } + } + + // If class has skipThrottle and method doesn't override it + if (classSkipThrottle !== undefined && methodSkipThrottle === undefined) { + const shouldSkip = this.shouldSkipThrottle(classSkipThrottle); + if (shouldSkip) { + this.logger.debug( + `Skipping throttle for ${fieldName} due to class decorator`, + ); + return true; + } + } + + // Apply throttling + return super.canActivate(context); + } + + private shouldSkipThrottle(skipThrottle: any): boolean { + if (skipThrottle === true) { + return true; + } + + if (typeof skipThrottle === 'object') { + // Check if any throttler should be skipped + for (const key in skipThrottle) { + if (skipThrottle[key] === true) { + return true; + } + } + } + + return false; + } +} diff --git a/src/config/app.config.ts b/src/config/app.config.ts index ac9a2b2..4c4499e 100644 --- a/src/config/app.config.ts +++ b/src/config/app.config.ts @@ -1,14 +1,18 @@ import { join } from 'path'; +import * as dotenv from 'dotenv'; + +dotenv.config(); export const AppConfig = { - port: process.env.PORT || 3000, - graphqlUpload: { - maxFileSize: 10000000, - maxFiles: 10, - }, - staticFiles: { - uploadsPath: join(__dirname, '..', '..', 'uploads'), - }, - cors: true, - bodyParser: true, -}; \ No newline at end of file + port: process.env.PORT || 3000, + env: process.env.NODE_ENV || 'development', + graphqlUpload: { + maxFileSize: 10000000, + maxFiles: 10, + }, + staticFiles: { + uploadsPath: join(__dirname, '..', '..', 'uploads'), + }, + cors: true, + bodyParser: true, +}; diff --git a/src/graphql/schema.gql b/src/graphql/schema.gql index cef9743..9294839 100644 --- a/src/graphql/schema.gql +++ b/src/graphql/schema.gql @@ -16,4 +16,12 @@ scalar DateTime type Query { users: [User!]! + publicUserStats: String! + + """Health check endpoint""" + healthCheck: String! +} + +type Mutation { + login(email: String!, password: String!): String! } \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index 536d8e7..87972fc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,10 @@ import { NestFactory } from '@nestjs/core'; -import { ValidationPipe } from '@nestjs/common'; +import { ValidationPipe, Logger } from '@nestjs/common'; import { ExpressAdapter } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express'; import * as express from 'express'; import { graphqlUploadExpress } from 'graphql-upload-ts'; +import helmet from 'helmet'; import { AppModule } from './app.module'; import { AppConfig } from './config/app.config'; @@ -15,17 +16,36 @@ async function bootstrap() { { cors: AppConfig.cors, bodyParser: AppConfig.bodyParser, - } + logger: ['debug', 'error', 'warn', 'log'], + }, ); app.use( '/graphql', - graphqlUploadExpress(AppConfig.graphqlUpload), + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false, + crossOriginOpenerPolicy: false, + crossOriginResourcePolicy: false, + }), ); + app.use('/graphql', graphqlUploadExpress(AppConfig.graphqlUpload)); + + app.use((req, res, next) => { + if (req.path !== '/graphql') { + helmet()(req, res, next); + } else { + next(); + } + }); + app.useGlobalPipes(new ValidationPipe({ transform: true })); app.use('/uploads', express.static(AppConfig.staticFiles.uploadsPath)); + const logger = new Logger('Bootstrap'); await app.listen(AppConfig.port); + logger.log(`Application is running on port ${AppConfig.port}`); + logger.log(`GraphQL endpoint available at /graphql`); } -bootstrap(); \ No newline at end of file +bootstrap(); diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100644 index 0000000..fe3dc2b --- /dev/null +++ b/src/modules/health/health.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { MongooseModule } from '@nestjs/mongoose'; +import { HealthResolver } from './health.resolver'; +import { HealthService } from './health.service'; + +@Module({ + imports: [TerminusModule, MongooseModule.forFeature([])], + providers: [HealthResolver, HealthService], +}) +export class HealthModule {} diff --git a/src/modules/health/health.resolver.ts b/src/modules/health/health.resolver.ts new file mode 100644 index 0000000..264576f --- /dev/null +++ b/src/modules/health/health.resolver.ts @@ -0,0 +1,31 @@ +import { Query, Resolver } from '@nestjs/graphql'; +import { HealthService } from './health.service'; +import { HealthCheckService, MongooseHealthIndicator } from '@nestjs/terminus'; +import { SkipThrottle } from '@nestjs/throttler'; + +@Resolver('Health') +export class HealthResolver { + constructor( + private health: HealthCheckService, + private mongooseHealth: MongooseHealthIndicator, + private healthService: HealthService, + ) {} + + @Query(() => String, { description: 'Health check endpoint' }) + @SkipThrottle() + async healthCheck() { + try { + const healthCheck = await this.health.check([ + () => this.mongooseHealth.pingCheck('mongodb'), + () => this.healthService.checkApiStatus(), + ]); + + return JSON.stringify(healthCheck); + } catch (error) { + return JSON.stringify({ + status: 'error', + info: { error: error.message }, + }); + } + } +} diff --git a/src/modules/health/health.service.ts b/src/modules/health/health.service.ts new file mode 100644 index 0000000..6176626 --- /dev/null +++ b/src/modules/health/health.service.ts @@ -0,0 +1,19 @@ +import { Injectable } from '@nestjs/common'; +import { HealthIndicatorResult, HealthIndicatorStatus } from '@nestjs/terminus'; + +@Injectable() +export class HealthService { + async checkApiStatus(): Promise { + const isHealthy = true; // Replace with actual health logic if needed + + const result: HealthIndicatorResult = { + api: { + status: isHealthy + ? ('up' as HealthIndicatorStatus) + : ('down' as HealthIndicatorStatus), + }, + }; + + return result; + } +} diff --git a/src/modules/users/users.resolver.ts b/src/modules/users/users.resolver.ts index 267cfd1..b02c9c3 100644 --- a/src/modules/users/users.resolver.ts +++ b/src/modules/users/users.resolver.ts @@ -1,13 +1,33 @@ -import { Resolver, Query } from '@nestjs/graphql'; +import { Resolver, Query, Mutation, Args } from '@nestjs/graphql'; import { UsersService } from './users.service'; import { User } from './schemas/user.schema'; +import { SkipThrottle, Throttle } from '@nestjs/throttler'; @Resolver(() => User) export class UsersResolver { - constructor(private readonly usersService: UsersService) { } + constructor(private readonly usersService: UsersService) {} - @Query(() => [User], { name: 'users' }) - async findAll(): Promise { - return this.usersService.findAll(); - } -} \ No newline at end of file + @Query(() => [User], { name: 'users' }) + async findAll(): Promise { + return this.usersService.findAll(); + } + + // Example of an endpoint that skips rate limiting + @SkipThrottle() + @Query(() => String, { name: 'publicUserStats' }) + async getPublicStats(): Promise { + // Public stats logic would go here + return JSON.stringify({ totalUsers: 100, activeUsers: 50 }); + } + + // Authentication with default rate limiting + @Throttle({ default: { limit: 5, ttl: 60 } }) + @Mutation(() => String, { name: 'login' }) + async login( + @Args('email') email: string, + @Args('password') password: string, + ): Promise { + // Login logic would go here + return JSON.stringify({ token: 'mock-token', email, password }); + } +} diff --git a/yarn.lock b/yarn.lock index 85fdc5f..25e52f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1132,6 +1132,14 @@ jsonc-parser "3.0.0" pluralize "8.0.0" +"@nestjs/terminus@10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@nestjs/terminus/-/terminus-10.0.1.tgz#f91aaac539f21fcd1e40d6a87e62fea5fe862b8b" + integrity sha512-orQmQFdwN4QC2Oo30BrxEKzKAVeVluWQElgIe16NGvm597VqRH4b1GbKldVg6H8adehd/nR6RdCUyFozRdl2rA== + dependencies: + boxen "5.1.2" + check-disk-space "3.4.0" + "@nestjs/testing@^8.0.0": version "8.4.7" resolved "https://registry.yarnpkg.com/@nestjs/testing/-/testing-8.4.7.tgz#fe4f356c0e081e25fe8c899a65e91dd88947fd13" @@ -1139,6 +1147,11 @@ dependencies: tslib "2.4.0" +"@nestjs/throttler@^6.4.0": + version "6.4.0" + resolved "https://registry.yarnpkg.com/@nestjs/throttler/-/throttler-6.4.0.tgz#5060e2157f4e8b0cb7886eef367751700549de5e" + integrity sha512-osL67i0PUuwU5nqSuJjtUJZMkxAnYB4VldgYUMGzvYRJDCqGRFMWbsbzm/CkUtPLRL30I8T74Xgt/OQxnYokiA== + "@noble/hashes@^1.1.5": version "1.8.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" @@ -1989,6 +2002,13 @@ ajv@^8.0.0, ajv@^8.11.0, ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" +ansi-align@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" + integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== + dependencies: + string-width "^4.1.0" + ansi-colors@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" @@ -2306,6 +2326,20 @@ body-parser@^1.19.0: type-is "~1.6.18" unpipe "1.0.0" +boxen@5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" + integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== + dependencies: + ansi-align "^3.0.0" + camelcase "^6.2.0" + chalk "^4.1.0" + cli-boxes "^2.2.1" + string-width "^4.2.2" + type-fest "^0.20.2" + widest-line "^3.1.0" + wrap-ansi "^7.0.0" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2447,6 +2481,11 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +check-disk-space@3.4.0: + version "3.4.0" + resolved "https://registry.yarnpkg.com/check-disk-space/-/check-disk-space-3.4.0.tgz#eb8e69eee7a378fd12e35281b8123a8b4c4a8ff7" + integrity sha512-drVkSqfwA+TvuEhFipiR1OC9boEGZL5RrWvVsOthdcvQNXyCCuKkEiTOTXZ7qxSf/GLwq4GvzfrQD/Wz325hgw== + chokidar@3.5.3: version "3.5.3" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" @@ -2506,6 +2545,11 @@ class-validator@^0.14.2: libphonenumber-js "^1.11.1" validator "^13.9.0" +cli-boxes@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" + integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== + cli-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" @@ -3623,6 +3667,11 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +helmet@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/helmet/-/helmet-8.1.0.tgz#f96d23fedc89e9476ecb5198181009c804b8b38c" + integrity sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg== + html-escaper@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" @@ -5662,7 +5711,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -6259,6 +6308,13 @@ which@^2.0.1: dependencies: isexe "^2.0.0" +widest-line@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" + integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== + dependencies: + string-width "^4.0.0" + windows-release@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-4.0.0.tgz#4725ec70217d1bf6e02c7772413b29cdde9ec377"