init commit
This commit is contained in:
commit
2346fe4879
45 changed files with 2573 additions and 0 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
node_modules/
|
||||||
|
.git/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
.env*
|
||||||
|
.flaskenv*
|
||||||
|
!.env.project
|
||||||
|
!.env.vault
|
||||||
|
public_answers/
|
4
.env.example
Normal file
4
.env.example
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
DATABASE_URL=
|
||||||
|
SECRET_KEY=
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=
|
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
# compiled output
|
||||||
|
/dist
|
||||||
|
/node_modules
|
||||||
|
/build
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
pnpm-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
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# temp directory
|
||||||
|
.temp
|
||||||
|
.tmp
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
4
.prettierrc
Normal file
4
.prettierrc
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
FROM oven/bun:alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY bun.lock ./
|
||||||
|
COPY package.json ./
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
|
||||||
|
RUN bun install --production --frozen-lockfile
|
||||||
|
|
||||||
|
COPY prisma ./prisma/
|
||||||
|
RUN bunx prisma generate
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
EXPOSE 4000
|
||||||
|
|
||||||
|
CMD bunx prisma migrate deploy && bunx prisma db seed && bun run start:prod
|
18
build.ts
Normal file
18
build.ts
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
// @ts-ignore
|
||||||
|
await Bun.build({
|
||||||
|
entrypoints: ["./src/app.ts"],
|
||||||
|
format: "esm",
|
||||||
|
outdir: "./dist",
|
||||||
|
target: "node",
|
||||||
|
external: [
|
||||||
|
"@nestjs/websockets/socket-module",
|
||||||
|
"@nestjs/microservices",
|
||||||
|
"class-transformer/storage",
|
||||||
|
"@fastify/view",
|
||||||
|
"@nestjs/platform-express",
|
||||||
|
],
|
||||||
|
minify: {
|
||||||
|
whitespace: true,
|
||||||
|
syntax: true,
|
||||||
|
},
|
||||||
|
});
|
2
bunfig.toml
Normal file
2
bunfig.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[run]
|
||||||
|
bun = true
|
20
compose.dev.yaml
Normal file
20
compose.dev.yaml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: bracket
|
||||||
|
ports:
|
||||||
|
- '5432:5432'
|
||||||
|
volumes:
|
||||||
|
- database:/var/lib/postgresql/data
|
||||||
|
redis:
|
||||||
|
image: redis
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- '6379:6379'
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
database:
|
35
eslint.config.mjs
Normal file
35
eslint.config.mjs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
// @ts-check
|
||||||
|
import eslint from '@eslint/js';
|
||||||
|
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||||
|
import globals from 'globals';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: ['eslint.config.mjs'],
|
||||||
|
},
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...tseslint.configs.recommendedTypeChecked,
|
||||||
|
eslintPluginPrettierRecommended,
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
...globals.jest,
|
||||||
|
},
|
||||||
|
ecmaVersion: 5,
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
|
'@typescript-eslint/no-unsafe-argument': 'warn'
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
16
nest-cli.json
Normal file
16
nest-cli.json
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"entryFile": "app",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"plugins": [{
|
||||||
|
"name": "@nestjs/swagger",
|
||||||
|
"options": {
|
||||||
|
"dtoFileNameSuffix": [".dto.ts", ".entity.ts", ".response.ts", ".payload.ts"],
|
||||||
|
"introspectComments": true
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
60
package.json
Normal file
60
package.json
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
{
|
||||||
|
"name": "nestjs-template",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"description": "",
|
||||||
|
"author": "",
|
||||||
|
"private": true,
|
||||||
|
"license": "UNLICENSED",
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun build.ts",
|
||||||
|
"start": "bun --bun nest start",
|
||||||
|
"start:dev": "bun --hot ./src/app.ts",
|
||||||
|
"start:prod": "bun dist/app",
|
||||||
|
"lint:fix": "eslint . --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fastify/helmet": "^13.0.1",
|
||||||
|
"@fastify/multipart": "^9.0.3",
|
||||||
|
"@fastify/static": "^8.1.0",
|
||||||
|
"@keyv/redis": "^4.2.0",
|
||||||
|
"@nestjs/cache-manager": "^3.0.0",
|
||||||
|
"@nestjs/common": "^11.0.8",
|
||||||
|
"@nestjs/config": "^4.0.0",
|
||||||
|
"@nestjs/core": "^11.0.8",
|
||||||
|
"@nestjs/jwt": "^11.0.0",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-fastify": "^11.0.8",
|
||||||
|
"@nestjs/schedule": "^5.0.1",
|
||||||
|
"@nestjs/swagger": "^11.0.3",
|
||||||
|
"@nestjs/throttler": "^6.4.0",
|
||||||
|
"@prisma/client": "6.3.1",
|
||||||
|
"cache-manager": "^6.4.0",
|
||||||
|
"cacheable": "^1.8.8",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"fastify": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"nodemailer": "^6.10.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"prisma": "^6.3.1",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"swagger-themes": "^1.4.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.2",
|
||||||
|
"@stylistic/eslint-plugin": "^3.0.1",
|
||||||
|
"@types/bun": "^1.2.2",
|
||||||
|
"@types/nodemailer": "^6.4.17",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/passport-local": "^1.0.38",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
|
"@typescript-eslint/parser": "^8.23.0",
|
||||||
|
"eslint": "^9.19.0",
|
||||||
|
"eslint-config-prettier": "^10.0.1",
|
||||||
|
"eslint-plugin-prettier": "^5.2.3",
|
||||||
|
"prettier": "^3.4.2",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.23.0"
|
||||||
|
}
|
||||||
|
}
|
15
prisma/migrations/20250207113919_init/migration.sql
Normal file
15
prisma/migrations/20250207113919_init/migration.sql
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" VARCHAR(36) NOT NULL,
|
||||||
|
"email" VARCHAR(320) NOT NULL,
|
||||||
|
"username" VARCHAR(30) NOT NULL,
|
||||||
|
"password" TEXT NOT NULL,
|
||||||
|
"token_id" VARCHAR(64) NOT NULL,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
20
prisma/schema.prisma
Normal file
20
prisma/schema.prisma
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Users {
|
||||||
|
id String @id @db.VarChar(36)
|
||||||
|
email String @unique @db.VarChar(320)
|
||||||
|
username String @db.VarChar(30)
|
||||||
|
password String
|
||||||
|
token_id String @db.VarChar(64) // Authenticate JWT for security
|
||||||
|
created_at DateTime @default(now())
|
||||||
|
updated_at DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
16
src/app.controller.ts
Normal file
16
src/app.controller.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import {CacheInterceptor} from "@nestjs/cache-manager";
|
||||||
|
import {VersionEntity} from "./common/models/entities/version.entity";
|
||||||
|
import {Controller, Get, UseInterceptors} from "@nestjs/common";
|
||||||
|
import {ApiTags} from "@nestjs/swagger";
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
@ApiTags("Misc")
|
||||||
|
@UseInterceptors(CacheInterceptor)
|
||||||
|
export class AppController{
|
||||||
|
@Get("version")
|
||||||
|
async getVersion(): Promise<VersionEntity>{
|
||||||
|
return {
|
||||||
|
version: process.env.npm_package_version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
44
src/app.module.ts
Normal file
44
src/app.module.ts
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
import { HelperModule } from './common/modules/helper/helper.module';
|
||||||
|
import { ClassSerializerInterceptor, Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from './common/modules/auth/auth.module';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { CacheModule } from '@nestjs/cache-manager';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { AppController } from './app.controller';
|
||||||
|
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
import { CacheableMemory } from 'cacheable';
|
||||||
|
import KeyvRedis from '@keyv/redis';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AppController],
|
||||||
|
imports: [
|
||||||
|
ConfigModule.forRoot({ isGlobal: true }),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60000,
|
||||||
|
limit: 60,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
CacheModule.registerAsync({
|
||||||
|
isGlobal: true,
|
||||||
|
useFactory: async (): Promise<any> => {
|
||||||
|
const redisUrl: string | undefined = process.env.REDIS_URL;
|
||||||
|
return {
|
||||||
|
stores: [redisUrl ? new KeyvRedis(redisUrl) : new CacheableMemory()],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
HelperModule,
|
||||||
|
AuthModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: ClassSerializerInterceptor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
83
src/app.ts
Normal file
83
src/app.ts
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
import {FastifyAdapter, NestFastifyApplication} from "@nestjs/platform-fastify";
|
||||||
|
import {CustomValidationPipe} from "./common/pipes/custom-validation.pipe";
|
||||||
|
import {LoggerMiddleware} from "./common/middlewares/logger.middleware";
|
||||||
|
import {SwaggerTheme, SwaggerThemeNameEnum} from "swagger-themes";
|
||||||
|
import {DocumentBuilder, SwaggerModule} from "@nestjs/swagger";
|
||||||
|
import {FastifyListenOptions} from "fastify/types/instance";
|
||||||
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import fastifyHelmet from "@fastify/helmet";
|
||||||
|
import {NestFactory} from "@nestjs/core";
|
||||||
|
import {AppModule} from "./app.module";
|
||||||
|
import {Logger} from "@nestjs/common";
|
||||||
|
import * as process from "process";
|
||||||
|
import {join} from "node:path";
|
||||||
|
|
||||||
|
const logger: Logger = new Logger("App");
|
||||||
|
|
||||||
|
process.env.APP_NAME = process.env.npm_package_name.split("-").map((word: string): string => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
||||||
|
const port: number = parseInt(process.env.PORT) || 4000;
|
||||||
|
|
||||||
|
async function bootstrap(){
|
||||||
|
const app = await NestFactory.create<NestFastifyApplication>(
|
||||||
|
AppModule,
|
||||||
|
new FastifyAdapter({exposeHeadRoutes: true}),
|
||||||
|
);
|
||||||
|
await loadServer(app);
|
||||||
|
|
||||||
|
await app.listen({
|
||||||
|
port: port,
|
||||||
|
host: "0.0.0.0",
|
||||||
|
} as FastifyListenOptions);
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
logger.log(`Listening on http://0.0.0.0:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadServer(server: NestFastifyApplication){
|
||||||
|
// Config
|
||||||
|
server.setGlobalPrefix(process.env.PREFIX);
|
||||||
|
server.enableCors({
|
||||||
|
origin: "*",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middlewares
|
||||||
|
server.use(new LoggerMiddleware().use);
|
||||||
|
await server.register(fastifyMultipart as any);
|
||||||
|
await server.register(fastifyStatic as any, {
|
||||||
|
root: join(process.cwd(), "public_answers"),
|
||||||
|
prefix: "/public_answers/",
|
||||||
|
});
|
||||||
|
await server.register(fastifyHelmet as any, {
|
||||||
|
contentSecurityPolicy: false,
|
||||||
|
crossOriginEmbedderPolicy: false,
|
||||||
|
crossOriginOpenerPolicy: false,
|
||||||
|
crossOriginResourcePolicy: false,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
// Swagger
|
||||||
|
const config = new DocumentBuilder()
|
||||||
|
.setTitle(process.env.APP_NAME)
|
||||||
|
.setDescription(`Documentation for ${process.env.APP_NAME}`)
|
||||||
|
.setVersion(process.env.npm_package_version)
|
||||||
|
.addBearerAuth()
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const document = SwaggerModule.createDocument(server, config);
|
||||||
|
const theme = new SwaggerTheme();
|
||||||
|
const customCss = theme.getBuffer(SwaggerThemeNameEnum.DARK);
|
||||||
|
SwaggerModule.setup("api", server, document, {
|
||||||
|
swaggerOptions: {
|
||||||
|
filter: true,
|
||||||
|
displayRequestDuration: true,
|
||||||
|
persistAuthorization: true,
|
||||||
|
docExpansion: "none",
|
||||||
|
tagsSorter: "alpha",
|
||||||
|
operationsSorter: "method",
|
||||||
|
},
|
||||||
|
customCss,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.useGlobalPipes(new CustomValidationPipe());
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
52
src/common/middlewares/logger.middleware.ts
Normal file
52
src/common/middlewares/logger.middleware.ts
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
import {Injectable, Logger, NestMiddleware} from "@nestjs/common";
|
||||||
|
import {FastifyReply, FastifyRequest} from "fastify";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoggerMiddleware implements NestMiddleware{
|
||||||
|
static logger: Logger = new Logger(LoggerMiddleware.name);
|
||||||
|
|
||||||
|
use(req: FastifyRequest["raw"], res: FastifyReply["raw"], next: () => void){
|
||||||
|
const startTime = Date.now();
|
||||||
|
(res as any).on("finish", () => {
|
||||||
|
const path = req.url;
|
||||||
|
try{
|
||||||
|
const protocol = LoggerMiddleware.getProtocol(req);
|
||||||
|
const method = req.method;
|
||||||
|
if(method === "OPTIONS")
|
||||||
|
return;
|
||||||
|
const statusCode = res.statusCode;
|
||||||
|
const duration = Date.now() - startTime;
|
||||||
|
const resSize: any = res.getHeader("Content-Length") || "0";
|
||||||
|
const intResSize = parseInt(resSize);
|
||||||
|
LoggerMiddleware.logger.log(`${protocol} ${method} ${path} ${statusCode} ${duration}ms ${intResSize}`);
|
||||||
|
LoggerMiddleware.logRequestTime(path, method, duration);
|
||||||
|
}catch(e){
|
||||||
|
LoggerMiddleware.logger.warn(`Can't log route ${path} : ${e}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
static getProtocol(req: FastifyRequest["raw"]): string{
|
||||||
|
const localPort: number = req.connection.localPort;
|
||||||
|
if(!localPort)
|
||||||
|
return "H2";
|
||||||
|
return localPort.toString() === process.env.HTTPS_PORT ? "HTTPS" : "HTTP";
|
||||||
|
}
|
||||||
|
|
||||||
|
static logRequestTime(path: string, method: string, duration: number): void{
|
||||||
|
const thresholds: Record<string, number> = {
|
||||||
|
GET: 750,
|
||||||
|
POST: 1500,
|
||||||
|
PUT: 1500,
|
||||||
|
PATCH: 500,
|
||||||
|
DELETE: 500,
|
||||||
|
};
|
||||||
|
const threshold = thresholds[method];
|
||||||
|
if(threshold && duration > threshold){
|
||||||
|
LoggerMiddleware.logger.warn(
|
||||||
|
`${method} (${path}) request exceeded ${threshold}ms (${duration}ms)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
src/common/models/dto/pagination.dto.ts
Normal file
13
src/common/models/dto/pagination.dto.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import {IsNumber, IsOptional, Min} from "class-validator";
|
||||||
|
|
||||||
|
export class PaginationDto{
|
||||||
|
@IsNumber()
|
||||||
|
@Min(1)
|
||||||
|
@IsOptional()
|
||||||
|
take?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
@IsOptional()
|
||||||
|
skip?: number;
|
||||||
|
}
|
6
src/common/models/entities/version.entity.ts
Normal file
6
src/common/models/entities/version.entity.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import {ApiProperty} from "@nestjs/swagger";
|
||||||
|
|
||||||
|
export class VersionEntity{
|
||||||
|
@ApiProperty()
|
||||||
|
version: string;
|
||||||
|
}
|
6
src/common/models/responses/pagination.response.ts
Normal file
6
src/common/models/responses/pagination.response.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
export class PaginationResponse<T>{
|
||||||
|
data: T;
|
||||||
|
total: number;
|
||||||
|
take: number;
|
||||||
|
skip: number;
|
||||||
|
}
|
11
src/common/modules/auth/auth.controller.ts
Normal file
11
src/common/modules/auth/auth.controller.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { Controller, Delete, NotImplementedException } from "@nestjs/common";
|
||||||
|
|
||||||
|
@Controller("auth")
|
||||||
|
export class AuthController {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
@Delete("logout/all")
|
||||||
|
logoutAll() {
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
35
src/common/modules/auth/auth.module.ts
Normal file
35
src/common/modules/auth/auth.module.ts
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
import { RegisterController } from './register.controller';
|
||||||
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
|
import { LoginController } from './login.controller';
|
||||||
|
import { AuthController } from './auth.controller';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { LoginService } from './login.service';
|
||||||
|
import { RegisterService } from './register.service';
|
||||||
|
import { UsersModule } from '../../../modules/users/users.module';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { AuthJwtStrategy } from './strategies/auth-jwt.strategy';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthController, LoginController, RegisterController],
|
||||||
|
providers: [JwtStrategy, AuthJwtStrategy, LoginService, RegisterService],
|
||||||
|
imports: [
|
||||||
|
JwtModule.registerAsync({
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
secret: configService.get<string>('SECRET_KEY'),
|
||||||
|
signOptions: {
|
||||||
|
expiresIn: '7d',
|
||||||
|
algorithm: 'HS512',
|
||||||
|
issuer: configService.get<string>('APP_NAME'),
|
||||||
|
},
|
||||||
|
verifyOptions: {
|
||||||
|
algorithms: ['HS512'],
|
||||||
|
issuer: configService.get<string>('APP_NAME'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AuthModule {}
|
5
src/common/modules/auth/guards/jwt-auth.guard.ts
Normal file
5
src/common/modules/auth/guards/jwt-auth.guard.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import {Injectable} from "@nestjs/common";
|
||||||
|
import {AuthGuard} from "@nestjs/passport";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtAuthGuard extends AuthGuard("jwt"){}
|
94
src/common/modules/auth/login.controller.ts
Normal file
94
src/common/modules/auth/login.controller.ts
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
NotImplementedException,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UnauthorizedException,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { LoginPayload } from './models/payloads/login.payload';
|
||||||
|
import { LocalLoginDto } from './models/dto/local-login.dto';
|
||||||
|
import { UserEntity } from './models/entities/user.entity';
|
||||||
|
import { JwtScope } from './models/enums/jwt-scope';
|
||||||
|
import { LoginService } from './login.service';
|
||||||
|
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { UsersService } from '../../../modules/users/users.service';
|
||||||
|
|
||||||
|
@Controller('auth/login')
|
||||||
|
@ApiTags('Auth')
|
||||||
|
export class LoginController {
|
||||||
|
constructor(
|
||||||
|
private readonly loginService: LoginService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs in the user using email and password
|
||||||
|
*
|
||||||
|
* @throws {401} Invalid password
|
||||||
|
* @throws {404} User not found
|
||||||
|
* @throws {500} Internal server error
|
||||||
|
*/
|
||||||
|
@Post('')
|
||||||
|
async login(@Body() body: LocalLoginDto): Promise<LoginPayload> {
|
||||||
|
const user: UserEntity = await this.loginService.validateUser(
|
||||||
|
body.email,
|
||||||
|
body.password,
|
||||||
|
);
|
||||||
|
if (!(await this.loginService.isUserVerified(user.id)))
|
||||||
|
throw new UnauthorizedException('User not verified');
|
||||||
|
const token: string = this.loginService.generateToken(
|
||||||
|
user.id,
|
||||||
|
user.tokenId,
|
||||||
|
JwtScope.USAGE,
|
||||||
|
);
|
||||||
|
return new LoginPayload({
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the login callback for the authentication process (Magic Link, Passkey, 2FA)
|
||||||
|
*
|
||||||
|
* @throws {401} Unauthorized
|
||||||
|
* @throws {500} Internal server error
|
||||||
|
*/
|
||||||
|
@Post('callback')
|
||||||
|
@UseGuards(AuthGuard('auth-jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
loginCallback(@Req() req: any): Promise<LoginPayload> {
|
||||||
|
// TODO: Fix this line
|
||||||
|
req = req.user;
|
||||||
|
if (req.scope === JwtScope.MAGIC) {
|
||||||
|
// Check for 2FA/Passkey
|
||||||
|
const token: string = this.loginService.generateToken(
|
||||||
|
req.user.id,
|
||||||
|
req.user.tokenId,
|
||||||
|
JwtScope.USAGE,
|
||||||
|
);
|
||||||
|
return new LoginPayload({
|
||||||
|
user: req.user,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const authToken: string = this.loginService.generateToken(
|
||||||
|
req.user.id,
|
||||||
|
req.user.tokenId,
|
||||||
|
JwtScope.USAGE,
|
||||||
|
);
|
||||||
|
return new LoginPayload({
|
||||||
|
user: req.user,
|
||||||
|
token: authToken,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('passkey')
|
||||||
|
loginPasskey() {
|
||||||
|
// Generate JWT token
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
46
src/common/modules/auth/login.service.ts
Normal file
46
src/common/modules/auth/login.service.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { UserEntity } from './models/entities/user.entity';
|
||||||
|
import { CipherService } from '../helper/cipher.service';
|
||||||
|
import { PrismaService } from '../helper/prisma.service';
|
||||||
|
import { JwtScope } from './models/enums/jwt-scope';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { UsersService } from '../../../modules/users/users.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LoginService {
|
||||||
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly cipherService: CipherService,
|
||||||
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly usersService: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async validateUser(email: string, password: string): Promise<UserEntity> {
|
||||||
|
const user: UserEntity = await this.usersService.getUserByEmail(email);
|
||||||
|
if (!this.cipherService.comparePassword(password, user.password))
|
||||||
|
throw new UnauthorizedException('Invalid password');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async isUserVerified(userId: string): Promise<boolean> {
|
||||||
|
const user: UserEntity = await this.usersService.getUserById(userId);
|
||||||
|
return user.verified;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateToken(userId: string, userTokenId: string, scope: JwtScope): string {
|
||||||
|
return this.jwtService.sign(
|
||||||
|
{
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
subject: userId,
|
||||||
|
expiresIn: scope !== JwtScope.USAGE ? '5m' : '7d',
|
||||||
|
jwtid: userTokenId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
11
src/common/modules/auth/models/dto/local-login.dto.ts
Normal file
11
src/common/modules/auth/models/dto/local-login.dto.ts
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import {IsNotEmpty, IsString} from "class-validator";
|
||||||
|
|
||||||
|
export class LocalLoginDto{
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
email: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
password: string;
|
||||||
|
}
|
5
src/common/modules/auth/models/dto/register.dto.ts
Normal file
5
src/common/modules/auth/models/dto/register.dto.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
export class RegisterDto{
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}
|
20
src/common/modules/auth/models/entities/user.entity.ts
Normal file
20
src/common/modules/auth/models/entities/user.entity.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import {Exclude} from "class-transformer";
|
||||||
|
|
||||||
|
export class UserEntity{
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
username: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
verified: boolean;
|
||||||
|
|
||||||
|
@Exclude()
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
@Exclude()
|
||||||
|
tokenId: string;
|
||||||
|
|
||||||
|
constructor(partial: Partial<UserEntity>){
|
||||||
|
Object.assign(this, partial);
|
||||||
|
}
|
||||||
|
}
|
4
src/common/modules/auth/models/enums/jwt-scope.ts
Normal file
4
src/common/modules/auth/models/enums/jwt-scope.ts
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
export enum JwtScope {
|
||||||
|
AUTH = 'AUTH',
|
||||||
|
USAGE = 'USAGE',
|
||||||
|
}
|
9
src/common/modules/auth/models/payloads/login.payload.ts
Normal file
9
src/common/modules/auth/models/payloads/login.payload.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { UserEntity } from '../entities/user.entity';
|
||||||
|
export class LoginPayload {
|
||||||
|
user: UserEntity;
|
||||||
|
token: string;
|
||||||
|
|
||||||
|
constructor(partial: Partial<LoginPayload>) {
|
||||||
|
Object.assign(this, partial);
|
||||||
|
}
|
||||||
|
}
|
27
src/common/modules/auth/register.controller.ts
Normal file
27
src/common/modules/auth/register.controller.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
|
||||||
|
import { ApiTags } from '@nestjs/swagger';
|
||||||
|
import { RegisterDto } from './models/dto/register.dto';
|
||||||
|
import { RegisterService } from './register.service';
|
||||||
|
|
||||||
|
@Controller('auth/register')
|
||||||
|
@ApiTags('Auth')
|
||||||
|
export class RegisterController {
|
||||||
|
constructor(private readonly registerService: RegisterService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a new user
|
||||||
|
*
|
||||||
|
* @throws {400} Bad request
|
||||||
|
* @throws {409} Email already used
|
||||||
|
* @throws {500} Internal server error
|
||||||
|
*/
|
||||||
|
@Post('')
|
||||||
|
@HttpCode(204)
|
||||||
|
async register(@Body() body: RegisterDto): Promise<void> {
|
||||||
|
await this.registerService.register(
|
||||||
|
body.email,
|
||||||
|
body.username,
|
||||||
|
body.password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
45
src/common/modules/auth/register.service.ts
Normal file
45
src/common/modules/auth/register.service.ts
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../helper/prisma.service';
|
||||||
|
import { CipherService } from '../helper/cipher.service';
|
||||||
|
import {
|
||||||
|
EmailVerifications,
|
||||||
|
Passkeys,
|
||||||
|
TwoFactorAuth,
|
||||||
|
Users,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { UserEntity } from './models/entities/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RegisterService {
|
||||||
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly cipherService: CipherService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async register(
|
||||||
|
email: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const emailExists: Users = await this.prismaService.users.findFirst({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (emailExists) throw new ConflictException('Email already registered');
|
||||||
|
const hashedPassword: string = this.cipherService.hashPassword(password);
|
||||||
|
await this.prismaService.users.create({
|
||||||
|
data: {
|
||||||
|
id: Bun.randomUUIDv7(),
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
password: hashedPassword,
|
||||||
|
token_id: this.cipherService.generateRandomBytes(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
32
src/common/modules/auth/strategies/auth-jwt.strategy.ts
Normal file
32
src/common/modules/auth/strategies/auth-jwt.strategy.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { UsersService } from '../../../../modules/users/users.service';
|
||||||
|
import { JwtPayload } from 'jsonwebtoken';
|
||||||
|
import { JwtScope } from '../models/enums/jwt-scope';
|
||||||
|
import { UserEntity } from '../models/entities/user.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthJwtStrategy extends PassportStrategy(Strategy, 'auth-jwt') {
|
||||||
|
constructor(private readonly usersService: UsersService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: process.env.SECRET_KEY,
|
||||||
|
issuer: process.env.APP_NAME,
|
||||||
|
algorithms: ['HS512'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload): Promise<any> {
|
||||||
|
if (payload.scope === JwtScope.USAGE)
|
||||||
|
throw new UnauthorizedException('Invalid scope');
|
||||||
|
const user: UserEntity = await this.usersService.getUserById(payload.sub);
|
||||||
|
if (user.tokenId !== payload.jti)
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
scope: payload.scope,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
29
src/common/modules/auth/strategies/jwt.strategy.ts
Normal file
29
src/common/modules/auth/strategies/jwt.strategy.ts
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { UserEntity } from '../models/entities/user.entity';
|
||||||
|
import { JwtScope } from '../models/enums/jwt-scope';
|
||||||
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { UsersService } from '../../../../modules/users/users.service';
|
||||||
|
import { JwtPayload } from 'jsonwebtoken';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
|
constructor(private readonly usersService: UsersService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: process.env.SECRET_KEY,
|
||||||
|
issuer: process.env.APP_NAME,
|
||||||
|
algorithms: ['HS512'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: JwtPayload) {
|
||||||
|
if (payload.scope !== JwtScope.USAGE)
|
||||||
|
throw new UnauthorizedException('Invalid scope');
|
||||||
|
const user: UserEntity = await this.usersService.getUserById(payload.sub);
|
||||||
|
if (user.tokenId !== payload.jti)
|
||||||
|
throw new UnauthorizedException('Invalid token');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
173
src/common/modules/helper/cipher.service.ts
Normal file
173
src/common/modules/helper/cipher.service.ts
Normal file
|
@ -0,0 +1,173 @@
|
||||||
|
import {Injectable} from "@nestjs/common";
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CipherService{
|
||||||
|
// Hash functions
|
||||||
|
getSum(content: Bun.BlobOrStringOrBuffer): string{
|
||||||
|
if(!content) content = "";
|
||||||
|
return new Bun.CryptoHasher("sha256").update(content).digest().toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
hashPassword(content: Bun.StringOrBuffer, cost = 10): string{
|
||||||
|
return Bun.password.hashSync(content, {
|
||||||
|
algorithm: "argon2id",
|
||||||
|
timeCost: cost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
comparePassword(password: Bun.StringOrBuffer, hash: string): boolean{
|
||||||
|
return Bun.password.verifySync(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Symmetric functions
|
||||||
|
private prepareEncryptionKey(encryptionKey: string | Buffer): Buffer{
|
||||||
|
let keyBuffer: Buffer;
|
||||||
|
if(typeof encryptionKey === "string")
|
||||||
|
keyBuffer = Buffer.from(encryptionKey);
|
||||||
|
else
|
||||||
|
keyBuffer = encryptionKey;
|
||||||
|
const key = Buffer.alloc(64);
|
||||||
|
keyBuffer.copy(key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
cipherSymmetric(content: string, encryptionKey: string | Buffer): string{
|
||||||
|
if(!content) content = "";
|
||||||
|
return this.cipherBufferSymmetric(Buffer.from(content, "utf-8"), encryptionKey).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
cipherBufferSymmetric(content: Buffer, encryptionKey: string | Buffer): Buffer{
|
||||||
|
if(!content) content = Buffer.alloc(0);
|
||||||
|
const iv = crypto.randomBytes(12);
|
||||||
|
const key = this.prepareEncryptionKey(encryptionKey);
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-gcm", key.subarray(0, 32), iv);
|
||||||
|
const encrypted = Buffer.concat([cipher.update(content), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
return Buffer.concat([iv, encrypted, tag]);
|
||||||
|
}
|
||||||
|
|
||||||
|
decipherSymmetric(encryptedContent: string, encryptionKey: string | Buffer): string{
|
||||||
|
return this.decipherBufferSymmetric(Buffer.from(encryptedContent, "hex"), encryptionKey).toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
decipherBufferSymmetric(encryptedContent: Buffer, encryptionKey: string | Buffer): Buffer{
|
||||||
|
const iv = encryptedContent.subarray(0, 12);
|
||||||
|
const encrypted = encryptedContent.subarray(12, encryptedContent.length - 16);
|
||||||
|
const tag = encryptedContent.subarray(encryptedContent.length - 16);
|
||||||
|
const key = this.prepareEncryptionKey(encryptionKey);
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-gcm", key.subarray(0, 32), iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
try{
|
||||||
|
return Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||||
|
}catch(_){
|
||||||
|
throw new Error("Decryption failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cipherHardSymmetric(content: string, encryptionKey: string | Buffer, timeCost = 200000){
|
||||||
|
if(!content) content = "";
|
||||||
|
const salt = crypto.randomBytes(32);
|
||||||
|
const key = crypto.pbkdf2Sync(encryptionKey, salt, timeCost, 64, "sha512");
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-cbc", key.subarray(0, 32), iv);
|
||||||
|
let encrypted = cipher.update(content, "utf-8", "hex");
|
||||||
|
encrypted += cipher.final("hex");
|
||||||
|
const hmac = crypto.createHmac("sha256", key.subarray(32));
|
||||||
|
hmac.update(`${salt.toString("hex")}:${iv.toString("hex")}:${encrypted}`);
|
||||||
|
const digest = hmac.digest("hex");
|
||||||
|
return `${salt.toString("hex")}:${iv.toString("hex")}:${encrypted}:${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
decipherHardSymmetric(encryptedContent: string, encryptionKey: string | Buffer, timeCost = 200000){
|
||||||
|
const [saltString, ivString, encryptedString, digest] = encryptedContent.split(":");
|
||||||
|
const salt = Buffer.from(saltString, "hex");
|
||||||
|
const key = crypto.pbkdf2Sync(encryptionKey, salt, timeCost, 64, "sha512");
|
||||||
|
const iv = Buffer.from(ivString, "hex");
|
||||||
|
const hmac = crypto.createHmac("sha256", key.subarray(32));
|
||||||
|
hmac.update(`${saltString}:${ivString}:${encryptedString}`);
|
||||||
|
const calculatedDigest = hmac.digest("hex");
|
||||||
|
if(calculatedDigest !== digest)
|
||||||
|
throw new Error("Integrity check failed");
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-cbc", key.subarray(0, 32), iv);
|
||||||
|
let decrypted = decipher.update(encryptedString, "hex", "utf-8");
|
||||||
|
decrypted += decipher.final("utf-8");
|
||||||
|
return decrypted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asymmetric functions
|
||||||
|
generateKeyPair(modulusLength = 4096, privateEncryptionKey = null): crypto.KeyPairSyncResult<string, string>{
|
||||||
|
if(!privateEncryptionKey)
|
||||||
|
console.warn("No private encryption key provided, the private key will not be encrypted");
|
||||||
|
let options = undefined;
|
||||||
|
if(privateEncryptionKey){
|
||||||
|
options = {
|
||||||
|
cipher: "aes-256-cbc",
|
||||||
|
passphrase: privateEncryptionKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return crypto.generateKeyPairSync("rsa", {
|
||||||
|
modulusLength: modulusLength,
|
||||||
|
publicKeyEncoding: {
|
||||||
|
type: "spki",
|
||||||
|
format: "pem",
|
||||||
|
},
|
||||||
|
privateKeyEncoding: {
|
||||||
|
type: "pkcs8",
|
||||||
|
format: "pem",
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptAsymmetric(content: string, publicKey: string | Buffer): string{
|
||||||
|
if(!content) content = "";
|
||||||
|
const buffer = Buffer.from(content, "utf-8");
|
||||||
|
const encrypted = crypto.publicEncrypt({
|
||||||
|
key: publicKey,
|
||||||
|
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||||
|
}, buffer);
|
||||||
|
return encrypted.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptAsymmetric(encryptedContent: string, privateKey: string | Buffer, privateEncryptionKey = undefined): string{
|
||||||
|
const buffer = Buffer.from(encryptedContent, "base64");
|
||||||
|
if(!privateEncryptionKey)
|
||||||
|
return crypto.privateDecrypt({
|
||||||
|
key: privateKey,
|
||||||
|
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||||
|
}, buffer).toString("utf-8");
|
||||||
|
else
|
||||||
|
return crypto.privateDecrypt({
|
||||||
|
key: privateKey,
|
||||||
|
padding: crypto.constants.RSA_PKCS1_OAEP_PADDING,
|
||||||
|
passphrase: privateEncryptionKey,
|
||||||
|
}, buffer).toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret functions
|
||||||
|
/**
|
||||||
|
* Generate a random string
|
||||||
|
* @param bytes Number of bytes to generate
|
||||||
|
*/
|
||||||
|
generateRandomBytes(bytes = 32): string{
|
||||||
|
return crypto.randomBytes(bytes).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a random number
|
||||||
|
* @param numbersNumber Number of numbers to generate
|
||||||
|
*/
|
||||||
|
generateRandomNumbers(numbersNumber = 6): string{
|
||||||
|
return Array.from({length: numbersNumber}, () => Math.floor(Math.random() * 10)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
maskSensitiveInfo(str: string, visibleCount: number = 4, maskChar: string = "*"){
|
||||||
|
return str.slice(0, visibleCount) + maskChar.repeat(Math.max(0, str.length - visibleCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
maskEmail(email: string){
|
||||||
|
const [username, domain] = email.split("@");
|
||||||
|
return `${this.maskSensitiveInfo(username)}@${domain}`;
|
||||||
|
}
|
||||||
|
}
|
10
src/common/modules/helper/helper.module.ts
Normal file
10
src/common/modules/helper/helper.module.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { CipherService } from './cipher.service';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [CipherService, PrismaService],
|
||||||
|
exports: [CipherService, PrismaService],
|
||||||
|
})
|
||||||
|
export class HelperModule {}
|
32
src/common/modules/helper/prisma.service.ts
Normal file
32
src/common/modules/helper/prisma.service.ts
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
import {Injectable, Logger, OnModuleInit} from "@nestjs/common";
|
||||||
|
import {PrismaClient} from "@prisma/client";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PrismaService extends PrismaClient implements OnModuleInit{
|
||||||
|
static readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
|
constructor(){
|
||||||
|
super();
|
||||||
|
return this.$extends({
|
||||||
|
query: {
|
||||||
|
async $allOperations({operation, model, args, query}): Promise<any>{
|
||||||
|
const startTime: number = Date.now();
|
||||||
|
const result: any = await query(args);
|
||||||
|
const duration: number = Date.now() - startTime;
|
||||||
|
const requestCount: number = args.length || 1;
|
||||||
|
const resultCount: number = !result ? 0 : result.length || 1;
|
||||||
|
PrismaService.logger.log(`${model.toUpperCase()} ${operation.toLowerCase()} ${duration}ms ${requestCount} ${resultCount}`);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) as PrismaService;
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit(){
|
||||||
|
await this.$connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy(){
|
||||||
|
await this.$disconnect();
|
||||||
|
}
|
||||||
|
}
|
26
src/common/pipes/custom-validation.pipe.ts
Normal file
26
src/common/pipes/custom-validation.pipe.ts
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
import {BadRequestException, Logger, ValidationError, ValidationPipe} from "@nestjs/common";
|
||||||
|
|
||||||
|
export class CustomValidationPipe extends ValidationPipe{
|
||||||
|
private readonly logger = new Logger(CustomValidationPipe.name);
|
||||||
|
|
||||||
|
constructor(){
|
||||||
|
super({
|
||||||
|
transform: true,
|
||||||
|
transformOptions: {enableImplicitConversion: true},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createExceptionFactory(){
|
||||||
|
return (validationErrors: ValidationError[] = []) => {
|
||||||
|
if(this.isDetailedOutputDisabled){
|
||||||
|
return new BadRequestException();
|
||||||
|
}
|
||||||
|
const messages = validationErrors.map(error => ({
|
||||||
|
property: error.property,
|
||||||
|
constraints: error.constraints,
|
||||||
|
}));
|
||||||
|
// this.logger.error(messages);
|
||||||
|
return new BadRequestException(messages);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
16
src/modules/users/users.controller.ts
Normal file
16
src/modules/users/users.controller.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import {Controller, Get, Req, UseGuards} from "@nestjs/common";
|
||||||
|
import {JwtAuthGuard} from "../../common/modules/auth/guards/jwt-auth.guard";
|
||||||
|
import {UserEntity} from "../../common/modules/auth/models/entities/user.entity";
|
||||||
|
import {ApiBearerAuth} from "@nestjs/swagger";
|
||||||
|
|
||||||
|
@Controller("users")
|
||||||
|
export class UsersController{
|
||||||
|
constructor(){}
|
||||||
|
|
||||||
|
@Get("me")
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
getMyself(@Req() req: any): UserEntity{
|
||||||
|
return req.user;
|
||||||
|
}
|
||||||
|
}
|
10
src/modules/users/users.module.ts
Normal file
10
src/modules/users/users.module.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import {Module} from "@nestjs/common";
|
||||||
|
import {UsersService} from "./users.service";
|
||||||
|
import {UsersController} from "./users.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [UsersService],
|
||||||
|
exports: [UsersService],
|
||||||
|
controllers: [UsersController],
|
||||||
|
})
|
||||||
|
export class UsersModule{}
|
56
src/modules/users/users.service.ts
Normal file
56
src/modules/users/users.service.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import {Injectable, NotFoundException} from "@nestjs/common";
|
||||||
|
import {UserEntity} from "../../common/modules/auth/models/entities/user.entity";
|
||||||
|
import {PrismaService} from "../../common/modules/helper/prisma.service";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService{
|
||||||
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
){}
|
||||||
|
|
||||||
|
async getUserById(id: string){
|
||||||
|
const user = await this.prismaService.users.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
email_verifications: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if(!user)
|
||||||
|
throw new NotFoundException("User not found");
|
||||||
|
return new UserEntity({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
verified: !user.email_verifications,
|
||||||
|
password: user.password,
|
||||||
|
tokenId: user.token_id,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
updatedAt: user.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserByEmail(email: string): Promise<UserEntity>{
|
||||||
|
const user = await this.prismaService.users.findUnique({
|
||||||
|
where: {
|
||||||
|
email,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
email_verifications: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if(!user)
|
||||||
|
throw new NotFoundException("User not found");
|
||||||
|
return new UserEntity({
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
email: user.email,
|
||||||
|
verified: !user.email_verifications,
|
||||||
|
password: user.password,
|
||||||
|
tokenId: user.token_id,
|
||||||
|
createdAt: user.created_at,
|
||||||
|
updatedAt: user.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||||
|
}
|
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"declaration": true,
|
||||||
|
"removeComments": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"target": "ES2021",
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"baseUrl": "./",
|
||||||
|
"incremental": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strictNullChecks": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
|
"forceConsistentCasingInFileNames": false,
|
||||||
|
"noFallthroughCasesInSwitch": false
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue