diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 5da1b31..05d6755 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -1,3 +1,4 @@ +import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common'; import { Module, NestModule, MiddlewareConsumer, RequestMethod } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -16,6 +17,11 @@ import { PuzzlesModule } from './puzzles/puzzles.module'; import { QuestsModule } from './quests/quests.module'; import { StreakModule } from './streak/strerak.module'; import { CategoriesModule } from './categories/categories.module'; +import { TransactionMiddleware } from './middleware/transaction/transaction.middleware'; +import { TransactionLogger } from './middleware/transaction/transaction.logger'; +import { CompressionMiddleware } from './middleware/compression/compression.middleware'; +import { IdempotencyMiddleware } from './middleware/idempotency/idempotency.middleware'; +import { IdempotencyService } from './middleware/idempotency/idempotency.service'; import { JwtAuthModule, JwtAuthMiddleware } from './auth/middleware/jwt-auth.module'; import { REDIS_CLIENT } from './redis/redis.constants'; import jwtConfig from './auth/authConfig/jwt.config'; @@ -87,7 +93,6 @@ import { HealthModule } from './health/health.module'; CommonModule, RedisModule, BlockchainModule, - ProgressModule, CategoriesModule, // Register the custom JWT Auth Middleware module JwtAuthModule.registerAsync({ @@ -104,9 +109,16 @@ import { HealthModule } from './health/health.module'; HealthModule, ], controllers: [AppController], - providers: [AppService], + providers: [AppService, TransactionLogger], }) export class AppModule implements NestModule { + configure(consumer: MiddlewareConsumer) { + // Apply transaction middleware globally + consumer.apply(TransactionMiddleware).forRoutes('*'); + + // Apply compression middleware globally + consumer.apply(CompressionMiddleware).forRoutes('*'); + consumer.apply(IdempotencyMiddleware).forRoutes('*'); // new /** * Apply the JWT Authentication Middleware to all routes except public ones. */ diff --git a/backend/src/middleware/compression/compression.config.ts b/backend/src/middleware/compression/compression.config.ts new file mode 100644 index 0000000..8dfa33c --- /dev/null +++ b/backend/src/middleware/compression/compression.config.ts @@ -0,0 +1,20 @@ +export const COMPRESSION_CONFIG = { + threshold: 1024, // minimum size in bytes + gzip: { level: 6 }, // balance speed vs size + brotli: { quality: 4 }, // modern browsers + skipTypes: [ + /^image\//, + /^video\//, + /^audio\//, + /^application\/zip/, + /^application\/gzip/, + ], + compressibleTypes: [ + 'application/json', + 'text/html', + 'text/plain', + 'application/javascript', + 'text/css', + 'text/xml', + ], +}; diff --git a/backend/src/middleware/compression/compression.middleware.ts b/backend/src/middleware/compression/compression.middleware.ts new file mode 100644 index 0000000..c2c5081 --- /dev/null +++ b/backend/src/middleware/compression/compression.middleware.ts @@ -0,0 +1,63 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import * as zlib from 'zlib'; +import { COMPRESSION_CONFIG } from './compression.config'; + +@Injectable() +export class CompressionMiddleware implements NestMiddleware { + use(req: Request, res: Response, next: NextFunction) { + const acceptEncoding = req.headers['accept-encoding'] || ''; + const chunks: Buffer[] = []; + const originalWrite = res.write; + const originalEnd = res.end; + + // Intercept response body + res.write = function (chunk: any) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return true; + }; + + res.end = function (chunk: any) { + if (chunk) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + const body = Buffer.concat(chunks); + + // Skip compression if too small + if (body.length < COMPRESSION_CONFIG.threshold) { + return originalEnd.call(res, body); + } + + const contentType = res.getHeader('Content-Type') as string; + if (COMPRESSION_CONFIG.skipTypes.some((regex) => regex.test(contentType))) { + return originalEnd.call(res, body); + } + + // Select algorithm + if (/\bbr\b/.test(acceptEncoding)) { + zlib.brotliCompress(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: COMPRESSION_CONFIG.brotli.quality } }, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'br'); + originalEnd.call(res, compressed); + }); + } else if (/\bgzip\b/.test(acceptEncoding)) { + zlib.gzip(body, { level: COMPRESSION_CONFIG.gzip.level }, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'gzip'); + originalEnd.call(res, compressed); + }); + } else if (/\bdeflate\b/.test(acceptEncoding)) { + zlib.deflate(body, (err, compressed) => { + if (err) return originalEnd.call(res, body); + res.setHeader('Content-Encoding', 'deflate'); + originalEnd.call(res, compressed); + }); + } else { + return originalEnd.call(res, body); + } + }; + + next(); + } +} diff --git a/backend/src/middleware/idempotency/idempotency.config.ts b/backend/src/middleware/idempotency/idempotency.config.ts new file mode 100644 index 0000000..df590c1 --- /dev/null +++ b/backend/src/middleware/idempotency/idempotency.config.ts @@ -0,0 +1,9 @@ +export const IDEMPOTENCY_CONFIG = { + ttl: { + puzzleSubmission: 300, // 5 minutes + pointClaim: 600, // 10 minutes + friendRequest: 3600, // 1 hour + profileUpdate: 60, // 1 minute + }, + headerKey: 'x-idempotency-key', +}; diff --git a/backend/src/middleware/idempotency/idempotency.middleware.ts b/backend/src/middleware/idempotency/idempotency.middleware.ts new file mode 100644 index 0000000..fa7e3cc --- /dev/null +++ b/backend/src/middleware/idempotency/idempotency.middleware.ts @@ -0,0 +1,56 @@ +import { Injectable, NestMiddleware, BadRequestException } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { IdempotencyService } from './idempotency.service'; +import { IDEMPOTENCY_CONFIG } from './idempotency.config'; + +@Injectable() +export class IdempotencyMiddleware implements NestMiddleware { + constructor(private readonly idempotencyService: IdempotencyService) {} + + async use(req: Request, res: Response, next: NextFunction) { + // Skip GET requests + if (req.method === 'GET') return next(); + + const headerKey = IDEMPOTENCY_CONFIG.headerKey; + let idempotencyKey = req.headers[headerKey] as string; + + if (!idempotencyKey) { + // Auto-generate key if not provided + idempotencyKey = await this.idempotencyService.generateKey(req); + } + + if (typeof idempotencyKey !== 'string') { + throw new BadRequestException('Invalid idempotency key format'); + } + + const cachedResponse = await this.idempotencyService.getResponse(idempotencyKey); + if (cachedResponse) { + // Return cached response immediately + res.set(cachedResponse.headers); + return res.status(cachedResponse.statusCode).send(cachedResponse.body); + } + + // Intercept response to store it + const originalSend = res.send.bind(res); + res.send = async (body: any) => { + const ttl = this.resolveTTL(req.originalUrl); + const responsePayload = { + statusCode: res.statusCode, + headers: res.getHeaders(), + body, + }; + await this.idempotencyService.storeResponse(idempotencyKey, responsePayload, ttl); + return originalSend(body); + }; + + next(); + } + + private resolveTTL(url: string): number { + if (url.includes('/puzzles')) return IDEMPOTENCY_CONFIG.ttl.puzzleSubmission; + if (url.includes('/points')) return IDEMPOTENCY_CONFIG.ttl.pointClaim; + if (url.includes('/friends')) return IDEMPOTENCY_CONFIG.ttl.friendRequest; + if (url.includes('/profile')) return IDEMPOTENCY_CONFIG.ttl.profileUpdate; + return 300; // default + } +} diff --git a/backend/src/middleware/idempotency/idempotency.service.ts b/backend/src/middleware/idempotency/idempotency.service.ts new file mode 100644 index 0000000..fc2d410 --- /dev/null +++ b/backend/src/middleware/idempotency/idempotency.service.ts @@ -0,0 +1,25 @@ +import { Injectable } from '@nestjs/common'; +import { RedisService } from '../../redis/redis.service'; +import * as crypto from 'crypto'; + +@Injectable() +export class IdempotencyService { + constructor(private readonly redisService: RedisService) {} + + async generateKey(req: any): Promise { + const userId = req.user?.id || 'anon'; + const bodyHash = crypto.createHash('sha256').update(JSON.stringify(req.body)).digest('hex'); + return `${userId}:${req.method}:${req.originalUrl}:${bodyHash}`; + } + + async storeResponse(key: string, response: any, ttl: number) { + const client = this.redisService.getClient(); + await client.set(key, JSON.stringify(response), 'EX', ttl, 'NX'); // SETNX for atomicity + } + + async getResponse(key: string): Promise { + const client = this.redisService.getClient(); + const data = await client.get(key); + return data ? JSON.parse(data) : null; + } +} diff --git a/backend/src/middleware/transaction/transaction.logger.ts b/backend/src/middleware/transaction/transaction.logger.ts new file mode 100644 index 0000000..2e92309 --- /dev/null +++ b/backend/src/middleware/transaction/transaction.logger.ts @@ -0,0 +1,14 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class TransactionLogger { + private readonly logger = new Logger('Transaction'); + + log(message: string) { + this.logger.log(message); + } + + error(message: string, error: any) { + this.logger.error(`${message}: ${error.message}`, error.stack); + } +} diff --git a/backend/src/middleware/transaction/transaction.manager.ts b/backend/src/middleware/transaction/transaction.manager.ts new file mode 100644 index 0000000..8175ec7 --- /dev/null +++ b/backend/src/middleware/transaction/transaction.manager.ts @@ -0,0 +1,36 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +export class TransactionManager { + private queryRunner: QueryRunner; + + constructor(private readonly dataSource: DataSource) { + this.queryRunner = this.dataSource.createQueryRunner(); + } + + async startTransaction(isolation: 'READ COMMITTED' | 'REPEATABLE READ' | 'SERIALIZABLE' = 'READ COMMITTED') { + await this.queryRunner.connect(); + await this.queryRunner.startTransaction(isolation); + } + + async commitTransaction() { + await this.queryRunner.commitTransaction(); + await this.queryRunner.release(); + } + + async rollbackTransaction() { + await this.queryRunner.rollbackTransaction(); + await this.queryRunner.release(); + } + + async createSavepoint(name: string) { + await this.queryRunner.query(`SAVEPOINT ${name}`); + } + + async rollbackToSavepoint(name: string) { + await this.queryRunner.query(`ROLLBACK TO SAVEPOINT ${name}`); + } + + getManager() { + return this.queryRunner.manager; + } +} diff --git a/backend/src/middleware/transaction/transaction.middleware.ts b/backend/src/middleware/transaction/transaction.middleware.ts new file mode 100644 index 0000000..11bb680 --- /dev/null +++ b/backend/src/middleware/transaction/transaction.middleware.ts @@ -0,0 +1,37 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { DataSource } from 'typeorm'; +import { TransactionManager } from './transaction.manager'; +import { TransactionLogger } from './transaction.logger'; + +@Injectable() +export class TransactionMiddleware implements NestMiddleware { + constructor(private readonly dataSource: DataSource, private readonly logger: TransactionLogger) {} + + async use(req: Request, res: Response, next: NextFunction) { + const manager = new TransactionManager(this.dataSource); + + try { + await manager.startTransaction(); + + // Attach transaction manager to request for manual control if needed + (req as any).transactionManager = manager; + + res.on('finish', async () => { + if (res.statusCode >= 200 && res.statusCode < 400) { + await manager.commitTransaction(); + this.logger.log('Transaction committed successfully'); + } else { + await manager.rollbackTransaction(); + this.logger.log('Transaction rolled back due to error'); + } + }); + + next(); + } catch (error) { + await manager.rollbackTransaction(); + this.logger.error('Transaction failed', error); + next(error); + } + } +} diff --git a/package-lock.json b/package-lock.json index 6f7fb80..5beb79b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@nestjs/common": "^11.1.14", "@nestjs/core": "^11.1.14", "@tanstack/react-query": "^5.90.21", + "compression": "^1.8.1", "framer-motion": "^12.34.3", "minimatch": "^10.1.1", "reflect-metadata": "^0.2.2", @@ -8005,6 +8006,60 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -14287,6 +14342,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index e8233f0..94643ec 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@nestjs/common": "^11.1.14", "@nestjs/core": "^11.1.14", "@tanstack/react-query": "^5.90.21", + "compression": "^1.8.1", "framer-motion": "^12.34.3", "minimatch": "^10.1.1", "reflect-metadata": "^0.2.2",