├── .gitattributes ├── .npmrc ├── src ├── i18n │ ├── en │ │ ├── translations.json │ │ └── errors.json │ └── ru │ │ ├── translations.json │ │ └── errors.json ├── app │ ├── profile │ │ ├── types │ │ │ ├── profile-role.enum.ts │ │ │ ├── profile-update.input.ts │ │ │ └── profile.object-type.ts │ │ ├── profile.module.ts │ │ ├── profile.service.ts │ │ └── profile.resolver.ts │ ├── debug │ │ ├── debug.module.ts │ │ └── debug.resolver.ts │ ├── health │ │ ├── health.module.ts │ │ └── health.controller.ts │ ├── app.controller.ts │ ├── file-upload │ │ ├── file-upload.module.ts │ │ ├── file-upload.controller.ts │ │ └── file-upload.service.ts │ ├── test-queue │ │ ├── test-queue.resolver.ts │ │ ├── test-queue.processor.ts │ │ └── test-queue.module.ts │ └── app.module.ts ├── common │ ├── auth │ │ ├── roles.decorator.ts │ │ ├── auth.module.ts │ │ ├── jwt-optional-auth.guard.ts │ │ ├── current-user.decorator.ts │ │ ├── roles.guard.ts │ │ ├── jwt.strategy.ts │ │ └── jwt-auth.guard.ts │ ├── logger-serve │ │ ├── logger-serve.module.ts │ │ ├── auth │ │ │ └── auth.guard.ts │ │ ├── logger-serve.controller.ts │ │ └── logger-ui.html.ts │ ├── dotenv-validator │ │ ├── dotenv-validator.module.ts │ │ └── dotenv-validator.service.ts │ ├── prisma │ │ ├── prisma.module.ts │ │ └── prisma.service.ts │ ├── logger │ │ ├── logger.module.ts │ │ └── pino-config.ts │ ├── prisma-studio │ │ ├── prisma-studio.module.ts │ │ ├── prisma-studio.controller.ts │ │ └── prisma-studio.service.ts │ ├── real-ip │ │ └── real-ip.decorator.ts │ ├── git-commit-saver.ts │ ├── bull-board │ │ └── bull-board.module.ts │ ├── graphql │ │ └── error-formatter.ts │ └── all-exceptions-filter.ts ├── @generated │ └── i18n-types.ts ├── main.ts └── db-backup-tool │ ├── restore.ts │ ├── logger.ts │ └── backup.ts ├── assets ├── logo.png └── logo.svg ├── .dockerignore ├── lefthook.yml ├── tsconfig.build.json ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20251120082752_init │ │ └── migration.sql └── schema.prisma ├── Dockerfile.database-backup ├── healthcheck.sh ├── nest-cli.json ├── Dockerfile ├── .editorconfig ├── vitest.config.ts ├── knip.json ├── .gitignore ├── prisma.config.ts ├── todo.md ├── test └── app.e2e.spec.ts ├── LICENSE ├── .env.example ├── biome.json ├── tsconfig.json ├── docker-compose.yml ├── package.json └── readme.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /src/i18n/en/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "Hello {username}!" 3 | } 4 | -------------------------------------------------------------------------------- /src/i18n/ru/translations.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "Привет {username}!" 3 | } 4 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uxname/liteend/HEAD/assets/logo.png -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | docker-compose.yml 4 | Dockerfile 5 | /dist 6 | /data 7 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | jobs: 3 | - name: Check project 4 | run: npm run check 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /Dockerfile.database-backup: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | RUN apk add openssl postgresql && rm -rf /var/cache/apk/* 3 | WORKDIR /app 4 | RUN npm i --legacy-peer-deps tsx 5 | COPY ./src/db-backup-tool . 6 | RUN ls -R /app 7 | ENTRYPOINT ["npx", "tsx", "/app/backup.ts"] 8 | -------------------------------------------------------------------------------- /src/app/profile/types/profile-role.enum.ts: -------------------------------------------------------------------------------- 1 | import { registerEnumType } from '@nestjs/graphql'; 2 | 3 | export enum ProfileRole { 4 | ADMIN = 'ADMIN', 5 | USER = 'USER', 6 | } 7 | 8 | registerEnumType(ProfileRole, { name: 'ProfileRole', description: undefined }); 9 | -------------------------------------------------------------------------------- /src/app/debug/debug.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { DebugResolver } from '@/app/debug/debug.resolver'; 4 | 5 | @Module({ 6 | providers: [DebugResolver], 7 | exports: [DebugResolver], 8 | }) 9 | export class DebugModule {} 10 | -------------------------------------------------------------------------------- /src/common/auth/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { ProfileRole } from '@/@generated/prisma/client'; 3 | 4 | export const ROLES_KEY = 'roles'; 5 | export const Roles = (...roles: ProfileRole[]) => SetMetadata(ROLES_KEY, roles); 6 | -------------------------------------------------------------------------------- /src/common/logger-serve/logger-serve.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { LoggerServeController } from './logger-serve.controller'; 4 | 5 | @Module({ 6 | controllers: [LoggerServeController], 7 | }) 8 | export class LoggerServeModule {} 9 | -------------------------------------------------------------------------------- /src/common/dotenv-validator/dotenv-validator.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { DotenvValidatorService } from './dotenv-validator.service'; 4 | 5 | @Module({ 6 | providers: [DotenvValidatorService], 7 | }) 8 | export class DotenvValidatorModule {} 9 | -------------------------------------------------------------------------------- /src/common/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | 3 | import { PrismaService } from './prisma.service'; 4 | 5 | @Global() 6 | @Module({ 7 | providers: [PrismaService], 8 | exports: [PrismaService], 9 | }) 10 | export class PrismaModule {} 11 | -------------------------------------------------------------------------------- /src/app/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { HealthController } from '@/app/health/health.controller'; 4 | 5 | @Module({ 6 | providers: [HealthController], 7 | controllers: [HealthController], 8 | exports: [HealthController], 9 | }) 10 | export class HealthModule {} 11 | -------------------------------------------------------------------------------- /healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | HEALTHCHECK_URL="http://127.0.0.1:${PORT}/health" 4 | HEALTHCHECK_RESPONSE=$(wget -qO- "$HEALTHCHECK_URL") 5 | 6 | if echo "$HEALTHCHECK_RESPONSE" | grep -q "\"status\":\"ok\""; then 7 | echo "Healthcheck passed!" 8 | exit 0 9 | else 10 | echo "Healthcheck failed." 11 | exit 1 12 | fi 13 | -------------------------------------------------------------------------------- /src/app/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { All, Controller, NotFoundException } from '@nestjs/common'; 2 | import { ApiExcludeEndpoint } from '@nestjs/swagger'; 3 | 4 | @Controller() 5 | export class AppController { 6 | @ApiExcludeEndpoint() 7 | @All() 8 | root(): void { 9 | throw new NotFoundException(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/profile/profile.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ProfileResolver } from './profile.resolver'; 3 | import { ProfileService } from './profile.service'; 4 | 5 | @Module({ 6 | providers: [ProfileService, ProfileResolver], 7 | exports: [ProfileService], 8 | }) 9 | export class ProfileModule {} 10 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "assets": [ 7 | "**/schema.prisma", 8 | { 9 | "include": "i18n/**/*", 10 | "watchAssets": true 11 | } 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/common/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { LoggerModule as PinoLoggerModule } from 'nestjs-pino'; 3 | import { pinoConfig } from '@/common/logger/pino-config'; 4 | 5 | @Global() 6 | @Module({ 7 | imports: [PinoLoggerModule.forRoot(pinoConfig)], 8 | }) 9 | export class LoggerModule {} 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | RUN apk add python3 git openssl && rm -rf /var/cache/apk/* git 3 | WORKDIR /app 4 | COPY package*.json ./ 5 | # git required for lefthook 6 | COPY .git .git 7 | RUN npm i 8 | COPY . . 9 | RUN npm run db:gen && npm run build && chmod +x ./healthcheck.sh && rm -rf .git .env 10 | ENV NODE_ENV=production 11 | CMD ["npm", "run", "start:prod"] 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [*.ts] 16 | ij_typescript_use_double_quotes = false 17 | 18 | [*.js] 19 | ij_javascript_use_double_quotes = false 20 | -------------------------------------------------------------------------------- /src/app/file-upload/file-upload.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { FileUploadController } from '@/app/file-upload/file-upload.controller'; 4 | 5 | import { FileUploadService } from './file-upload.service'; 6 | 7 | @Module({ 8 | providers: [FileUploadController, FileUploadService], 9 | controllers: [FileUploadController], 10 | exports: [FileUploadController], 11 | }) 12 | export class FileUploadModule {} 13 | -------------------------------------------------------------------------------- /src/common/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { JwtStrategy } from './jwt.strategy'; 4 | import { JwtAuthGuard } from './jwt-auth.guard'; 5 | 6 | @Module({ 7 | imports: [PassportModule.register({ defaultStrategy: 'jwt' })], 8 | providers: [JwtStrategy, JwtAuthGuard], 9 | exports: [PassportModule, JwtAuthGuard], 10 | }) 11 | export class AuthModule {} 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import swc from 'unplugin-swc'; 2 | import tsconfigPaths from 'vite-tsconfig-paths'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | globals: true, 8 | root: './', 9 | }, 10 | plugins: [ 11 | tsconfigPaths(), 12 | // The SWC plugin is needed for the correct operation of NestJS decorators. 13 | swc.vite({ 14 | module: { type: 'nodenext' }, 15 | }), 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@5/schema.json", 3 | "entry": ["src/main.ts!"], 4 | "project": ["src/**/*.ts!", "test/**/*.ts", "!src/db-backup-tool/**"], 5 | "ignore": ["src/@generated/**", "src/common/git-commit-saver.ts"], 6 | "ignoreDependencies": [ 7 | "cross-env", 8 | "pino-pretty", 9 | "pino-roll", 10 | "graphql-ws", 11 | "@dotenvx/dotenvx", 12 | "@prisma/client" 13 | ], 14 | "paths": { 15 | "@/*": ["src/*"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/i18n/en/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "accountSuspended": "Account Suspended", 3 | "unauthorized": "Unauthorized", 4 | "accountHasNoRole": "Your roles ({accountRoles}) are not allowed ({allowedRoles})", 5 | "invalidPassword": "Invalid password", 6 | "accountNotFound": "Account not found", 7 | "emailNotSent": "Email not sent", 8 | "invalidCode": "Invalid code", 9 | "profileNotFound": "Profile not found", 10 | "accountsNotFound": "Accounts not found", 11 | "invalidToken": "Invalid token", 12 | "totpTokenNotSet": "TOTP token not set" 13 | } 14 | -------------------------------------------------------------------------------- /src/i18n/ru/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | "accountSuspended": "Аккаунт заблокирован", 3 | "unauthorized": "Неавторизован", 4 | "accountHasNoRole": "Ваши роли ({accountRoles}) не разрешены ({allowedRoles})", 5 | "invalidPassword": "Неверный пароль", 6 | "accountNotFound": "Аккаунт не найден", 7 | "emailNotSent": "Ошибка отправки email", 8 | "invalidCode": "Неверный код", 9 | "profileNotFound": "Профиль не найден", 10 | "accountsNotFound": "Аккаунты не найдены", 11 | "invalidToken": "Неверный токен", 12 | "totpTokenNotSet": "Токен не установлен" 13 | } 14 | -------------------------------------------------------------------------------- /src/app/profile/types/profile-update.input.ts: -------------------------------------------------------------------------------- 1 | import { Field, InputType } from '@nestjs/graphql'; 2 | import { createZodDto } from 'nestjs-zod'; 3 | import { z } from 'zod'; 4 | 5 | const ProfileUpdateSchema = z.object({ 6 | avatarUrl: z.url({ message: 'Avatar URL must be a valid URL' }).optional(), 7 | }); 8 | 9 | class ProfileUpdateZodDto extends createZodDto(ProfileUpdateSchema) {} 10 | 11 | @InputType() 12 | export class ProfileUpdateInput extends ProfileUpdateZodDto { 13 | @Field(() => String, { nullable: true }) 14 | declare avatarUrl?: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/common/auth/jwt-optional-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtAuthGuard } from './jwt-auth.guard'; 3 | 4 | @Injectable() 5 | export class JwtOptionalAuthGuard extends JwtAuthGuard { 6 | // biome-ignore lint/suspicious/noExplicitAny: Override handleRequest to prevent throwing UnauthorizedException 7 | handleRequest(err: any, user: any) { 8 | // If there is an error (e.g. invalid token) or no user, just return null. 9 | // Do not throw an exception. 10 | if (err || !user) { 11 | return null; 12 | } 13 | return user; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/profile/profile.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { Profile } from '@/@generated/prisma/client'; 3 | import { PrismaService } from '@/common/prisma/prisma.service'; 4 | import { ProfileUpdateInput } from './types/profile-update.input'; 5 | 6 | @Injectable() 7 | export class ProfileService { 8 | constructor(private readonly prisma: PrismaService) {} 9 | 10 | async updateProfile(id: number, input: ProfileUpdateInput): Promise { 11 | return this.prisma.profile.update({ 12 | where: { id }, 13 | data: { 14 | ...input, 15 | }, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/profile/types/profile.object-type.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, ObjectType } from '@nestjs/graphql'; 2 | 3 | import { ProfileRole } from '@/app/profile/types/profile-role.enum'; 4 | 5 | @ObjectType() 6 | export class Profile { 7 | @Field(() => Int, { nullable: false }) 8 | id!: number; 9 | 10 | @Field(() => Date, { nullable: false }) 11 | createdAt!: Date; 12 | 13 | @Field(() => Date, { nullable: false }) 14 | updatedAt!: Date; 15 | 16 | @Field(() => [ProfileRole], { nullable: true }) 17 | roles!: Array; 18 | 19 | @Field(() => String, { nullable: true }) 20 | avatarUrl!: string | null; 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | /data/ 37 | .env 38 | tsconfig.tsbuildinfo 39 | .husky 40 | 41 | src/@generated/prisma/ 42 | -------------------------------------------------------------------------------- /src/app/test-queue/test-queue.resolver.ts: -------------------------------------------------------------------------------- 1 | import { InjectQueue } from '@nestjs/bullmq'; 2 | import { Args, Mutation, Resolver } from '@nestjs/graphql'; 3 | import { Queue } from 'bullmq'; 4 | 5 | @Resolver() 6 | export class TestQueueResolver { 7 | constructor(@InjectQueue('test') private readonly testQueue: Queue) {} 8 | 9 | @Mutation(() => Boolean, { description: 'Adds a job to the test queue' }) 10 | async addTestJob( 11 | @Args('message', { type: () => String }) message: string, 12 | ): Promise { 13 | await this.testQueue.add('test-job', { 14 | message, 15 | date: new Date().toISOString(), 16 | }); 17 | return true; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/common/auth/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | import { Profile } from '@/@generated/prisma/client'; 4 | 5 | export type CurrentUserType = Profile; 6 | 7 | export const CurrentUser = createParamDecorator( 8 | (_data: unknown, context: ExecutionContext): CurrentUserType => { 9 | const ctx = GqlExecutionContext.create(context); 10 | const request = ctx.getContext().req; 11 | 12 | if (!request) { 13 | const httpRequest = context.switchToHttp().getRequest(); 14 | return httpRequest.user; 15 | } 16 | 17 | return request.user; 18 | }, 19 | ); 20 | -------------------------------------------------------------------------------- /src/app/test-queue/test-queue.processor.ts: -------------------------------------------------------------------------------- 1 | import { Processor, WorkerHost } from '@nestjs/bullmq'; 2 | import { Logger } from '@nestjs/common'; 3 | import { Job } from 'bullmq'; 4 | 5 | @Processor('test') 6 | export class TestQueueProcessor extends WorkerHost { 7 | private readonly logger = new Logger(TestQueueProcessor.name); 8 | 9 | async process(job: Job): Promise { 10 | this.logger.log( 11 | `Start processing job ${job.id} (${job.name}). Data: ${JSON.stringify(job.data)}`, 12 | ); 13 | 14 | await new Promise((resolve) => setTimeout(resolve, 1000)); 15 | 16 | this.logger.log(`Finished processing job ${job.id}`); 17 | return { result: 'Success' }; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /prisma.config.ts: -------------------------------------------------------------------------------- 1 | import '@dotenvx/dotenvx/config'; 2 | import { defineConfig, env } from 'prisma/config'; 3 | 4 | const DATABASE_HOST = env('DATABASE_HOST'); 5 | const DATABASE_PORT = env('DATABASE_PORT'); 6 | const DATABASE_USER = env('DATABASE_USER'); 7 | const DATABASE_PASSWORD = env('DATABASE_PASSWORD'); 8 | const DATABASE_NAME = env('DATABASE_NAME'); 9 | 10 | const DATABASE_URL = `postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}?schema=public`; 11 | 12 | export default defineConfig({ 13 | schema: 'prisma/schema.prisma', 14 | migrations: { 15 | path: 'prisma/migrations', 16 | }, 17 | datasource: { 18 | url: DATABASE_URL, 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/app/test-queue/test-queue.module.ts: -------------------------------------------------------------------------------- 1 | // src/app/test-queue/test-queue.module.ts 2 | import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; 3 | import { BullBoardModule } from '@bull-board/nestjs'; 4 | import { BullModule } from '@nestjs/bullmq'; 5 | import { Module } from '@nestjs/common'; 6 | import { TestQueueProcessor } from './test-queue.processor'; 7 | import { TestQueueResolver } from './test-queue.resolver'; 8 | 9 | @Module({ 10 | imports: [ 11 | BullModule.registerQueue({ 12 | name: 'test', 13 | }), 14 | BullBoardModule.forFeature({ 15 | name: 'test', 16 | adapter: BullMQAdapter, 17 | }), 18 | ], 19 | providers: [TestQueueProcessor, TestQueueResolver], 20 | }) 21 | export class TestQueueModule {} 22 | -------------------------------------------------------------------------------- /src/@generated/i18n-types.ts: -------------------------------------------------------------------------------- 1 | /* DO NOT EDIT, file generated by nestjs-i18n */ 2 | 3 | /* eslint-disable */ 4 | /* prettier-ignore */ 5 | import type { Path } from "nestjs-i18n"; 6 | /* prettier-ignore */ 7 | export type I18nTranslations = { 8 | "errors": { 9 | "accountSuspended": string; 10 | "unauthorized": string; 11 | "accountHasNoRole": string; 12 | "invalidPassword": string; 13 | "accountNotFound": string; 14 | "emailNotSent": string; 15 | "invalidCode": string; 16 | "profileNotFound": string; 17 | "accountsNotFound": string; 18 | "invalidToken": string; 19 | "totpTokenNotSet": string; 20 | }; 21 | "translations": { 22 | "hello": string; 23 | }; 24 | }; 25 | /* prettier-ignore */ 26 | export type I18nPath = Path; 27 | -------------------------------------------------------------------------------- /src/common/prisma-studio/prisma-studio.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module, OnModuleInit } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { PrismaStudioService } from '@/common/prisma-studio/prisma-studio.service'; 4 | 5 | import { PrismaStudioController } from './prisma-studio.controller'; 6 | 7 | @Module({ 8 | imports: [ConfigModule.forRoot()], 9 | providers: [PrismaStudioService], 10 | controllers: [PrismaStudioController], 11 | }) 12 | export class PrismaStudioModule implements OnModuleInit { 13 | private readonly logger = new Logger(PrismaStudioModule.name); 14 | 15 | constructor(readonly prismaStudioService: PrismaStudioService) {} 16 | 17 | async onModuleInit(): Promise { 18 | this.logger.log('Starting Prisma Studio...'); 19 | this.prismaStudioService.startStudio().catch((error) => { 20 | this.logger.error(error); 21 | }); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | ## General 4 | 5 | - [ ] (db_backup) Throw error when pg_dump not found database but no error message in logs 6 | - [ ] Change log levels to: verbose, debug, info, warn, error 7 | - [ ] Make logs format as JSON 8 | - [ ] Make run db and redis as non-root user 9 | - [ ] Implement db backup tool 10 | - [ ] Add rate limiter 11 | - [ ] Add rate limiter for /studio and /board 12 | - [ ] GraphQL integration tests 13 | - [ ] (Maybe) Implement simple mock service module 14 | - [ ] Improve readme file (add more info about project, add more examples, etc.) 15 | - [ ] Add log rotation into docker-compose.yml 16 | - [ ] DELETE THIS FILE 17 | 18 | ## OIDC Migration todo 19 | 20 | - [ ] Fix logs (log error stacktrace to stdout) 21 | - [ ] ``` 22 | throw new mercurius.ErrorWithProps('User not found', { 23 | code: 'USER_NOT_FOUND', 24 | timestamp: new Date().toISOString() 25 | }); 26 | ``` 27 | -------------------------------------------------------------------------------- /test/app.e2e.spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import * as pactum from 'pactum'; 4 | import { afterAll, beforeAll, describe, it } from 'vitest'; 5 | import { AppModule } from '@/app/app.module'; 6 | 7 | describe('AppController (e2e)', () => { 8 | let app: INestApplication; 9 | const port = 4001; 10 | 11 | beforeAll(async () => { 12 | const moduleFixture: TestingModule = await Test.createTestingModule({ 13 | imports: [AppModule], 14 | }).compile(); 15 | 16 | app = moduleFixture.createNestApplication(); 17 | 18 | await app.listen(port); 19 | 20 | pactum.request.setBaseUrl(`http://localhost:${port}`); 21 | }); 22 | 23 | afterAll(async () => { 24 | await app.close(); 25 | }); 26 | 27 | it('/health (GET)', () => { 28 | return pactum.spec().get('/health').expectStatus(200).expectJsonLike({ 29 | status: 'ok', 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | datasource db { 5 | provider = "postgresql" 6 | } 7 | 8 | generator client { 9 | provider = "prisma-client" 10 | output = "../src/@generated/prisma" 11 | engineType = "client" 12 | } 13 | 14 | model Profile { 15 | id Int @id @default(autoincrement()) 16 | createdAt DateTime @default(now()) 17 | updatedAt DateTime @updatedAt 18 | 19 | oidcSub String @unique 20 | 21 | roles ProfileRole[] @default([USER]) 22 | avatarUrl String? 23 | } 24 | 25 | enum ProfileRole { 26 | ADMIN 27 | USER 28 | } 29 | 30 | model Upload { 31 | id Int @id @default(autoincrement()) 32 | createdAt DateTime @default(now()) 33 | updatedAt DateTime @updatedAt 34 | 35 | filepath String @unique 36 | originalFilename String 37 | extension String 38 | size Int 39 | mimetype String 40 | 41 | uploaderIp String 42 | } 43 | -------------------------------------------------------------------------------- /src/common/real-ip/real-ip.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | import { FastifyRequest } from 'fastify'; 4 | 5 | export const RealIp = createParamDecorator( 6 | (_data: unknown, context: ExecutionContext): string => { 7 | let request: FastifyRequest; 8 | 9 | if (context.getType() === 'http') { 10 | request = context.switchToHttp().getRequest(); 11 | } else { 12 | const gqlContext = GqlExecutionContext.create(context); 13 | request = gqlContext.getContext().req; 14 | } 15 | 16 | if (request.ip) { 17 | return request.ip; 18 | } 19 | 20 | const headers = request.headers || {}; 21 | const xForwardedFor = headers['x-forwarded-for']; 22 | 23 | if (xForwardedFor) { 24 | return Array.isArray(xForwardedFor) 25 | ? xForwardedFor[0]! 26 | : xForwardedFor.split(',')[0]!; 27 | } 28 | 29 | return '127.0.0.1'; 30 | }, 31 | ); 32 | -------------------------------------------------------------------------------- /src/common/auth/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { GqlExecutionContext } from '@nestjs/graphql'; 4 | import { ProfileRole } from '@/@generated/prisma/client'; 5 | import { ROLES_KEY } from './roles.decorator'; 6 | 7 | @Injectable() 8 | export class RolesGuard implements CanActivate { 9 | constructor(private reflector: Reflector) {} 10 | 11 | canActivate(context: ExecutionContext): boolean { 12 | const requiredRoles = this.reflector.getAllAndOverride( 13 | ROLES_KEY, 14 | [context.getHandler(), context.getClass()], 15 | ); 16 | 17 | if (!requiredRoles) { 18 | return true; 19 | } 20 | 21 | const ctx = GqlExecutionContext.create(context); 22 | let user = ctx.getContext().req?.user; 23 | 24 | if (!user) { 25 | user = context.switchToHttp().getRequest().user; 26 | } 27 | 28 | if (!user || !user.roles) { 29 | return false; 30 | } 31 | 32 | return requiredRoles.some((role) => user.roles.includes(role)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 uxname 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /prisma/migrations/20251120082752_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "ProfileRole" AS ENUM ('ADMIN', 'USER'); 3 | 4 | -- CreateTable 5 | CREATE TABLE "Profile" ( 6 | "id" SERIAL NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL, 9 | "oidcSub" TEXT NOT NULL, 10 | "roles" "ProfileRole"[] DEFAULT ARRAY['USER']::"ProfileRole"[], 11 | "avatarUrl" TEXT, 12 | 13 | CONSTRAINT "Profile_pkey" PRIMARY KEY ("id") 14 | ); 15 | 16 | -- CreateTable 17 | CREATE TABLE "Upload" ( 18 | "id" SERIAL NOT NULL, 19 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 20 | "updatedAt" TIMESTAMP(3) NOT NULL, 21 | "filepath" TEXT NOT NULL, 22 | "originalFilename" TEXT NOT NULL, 23 | "extension" TEXT NOT NULL, 24 | "size" INTEGER NOT NULL, 25 | "mimetype" TEXT NOT NULL, 26 | "uploaderIp" TEXT NOT NULL, 27 | 28 | CONSTRAINT "Upload_pkey" PRIMARY KEY ("id") 29 | ); 30 | 31 | -- CreateIndex 32 | CREATE UNIQUE INDEX "Profile_oidcSub_key" ON "Profile"("oidcSub"); 33 | 34 | -- CreateIndex 35 | CREATE UNIQUE INDEX "Upload_filepath_key" ON "Upload"("filepath"); 36 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Application settings 2 | PORT=4000 3 | 4 | # Logs admin panel credentials 5 | LOGS_ADMIN_PANEL_USER=admin 6 | LOGS_ADMIN_PANEL_PASSWORD=admin 7 | 8 | # Database configuration 9 | DATABASE_HOST=localhost 10 | DATABASE_PORT=5432 11 | DATABASE_USER=postgres 12 | DATABASE_PASSWORD=postgres 13 | DATABASE_NAME=postgres 14 | 15 | # Database admin panel 16 | DB_ADMIN_PORT=5100 17 | DB_ADMIN_EMAIL=admin@admin.com 18 | DB_ADMIN_PASSWORD=admin 19 | 20 | # Redis configuration 21 | REDIS_HOST=localhost 22 | REDIS_PORT=6379 23 | REDIS_PASSWORD=redis 24 | REDIS_ADMIN_PORT=5200 25 | REDIS_ADMIN_USER=redis 26 | REDIS_ADMIN_PASSWORD=redis 27 | 28 | # Prisma Studio credentials 29 | PRISMA_STUDIO_LOGIN=admin 30 | PRISMA_STUDIO_PASSWORD=admin 31 | 32 | # Bull Board credentials 33 | BULL_BOARD_LOGIN=admin 34 | BULL_BOARD_PASSWORD=admin 35 | 36 | # OIDC Configuration 37 | OIDC_ISSUER=https://oalmxx.logto.app/oidc 38 | OIDC_AUDIENCE=https://oalmxx.logto.app/api 39 | OIDC_JWKS_URI=https://oalmxx.logto.app/oidc/jwks 40 | 41 | # Development mock OIDC user 42 | # Set to "true" to bypass OIDC and use a hardcoded user with ADMIN and USER roles. 43 | # WARNING: This should never be enabled in production. 44 | OIDC_MOCK_ENABLED=true 45 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "assist": { 4 | "actions": { 5 | "source": { 6 | "organizeImports": "on" 7 | } 8 | } 9 | }, 10 | "files": { 11 | "ignoreUnknown": false, 12 | "includes": [ 13 | "**", 14 | "!**/node_modules", 15 | "!**/dist", 16 | "!**/src/@generated", 17 | "!**/data" 18 | ] 19 | }, 20 | "formatter": { 21 | "enabled": true, 22 | "indentStyle": "space", 23 | "lineEnding": "lf" 24 | }, 25 | "javascript": { 26 | "formatter": { 27 | "quoteStyle": "single" 28 | }, 29 | "parser": { 30 | "unsafeParameterDecoratorsEnabled": true 31 | } 32 | }, 33 | "linter": { 34 | "enabled": true, 35 | "rules": { 36 | "correctness": { 37 | "noUnusedFunctionParameters": "error", 38 | "noUnusedImports": "error", 39 | "noUnusedVariables": "error" 40 | }, 41 | "recommended": true, 42 | "style": { 43 | "noNonNullAssertion": "off", 44 | "useImportType": "off" 45 | }, 46 | "suspicious": { 47 | "noPrototypeBuiltins": "off" 48 | } 49 | } 50 | }, 51 | "vcs": { 52 | "clientKind": "git", 53 | "enabled": true, 54 | "useIgnoreFile": true 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/common/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PrismaPg } from '@prisma/adapter-pg'; 4 | import { Pool } from 'pg'; 5 | import { PrismaClient } from '@/@generated/prisma/client'; 6 | 7 | @Injectable() 8 | export class PrismaService 9 | extends PrismaClient 10 | implements OnModuleInit, OnModuleDestroy 11 | { 12 | constructor(configService: ConfigService) { 13 | const DATABASE_HOST = configService.getOrThrow('DATABASE_HOST'); 14 | const DATABASE_PORT = configService.getOrThrow('DATABASE_PORT'); 15 | const DATABASE_USER = configService.getOrThrow('DATABASE_USER'); 16 | const DATABASE_PASSWORD = 17 | configService.getOrThrow('DATABASE_PASSWORD'); 18 | const DATABASE_NAME = configService.getOrThrow('DATABASE_NAME'); 19 | 20 | const DATABASE_URL = `postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}?schema=public`; 21 | 22 | const pool = new Pool({ 23 | connectionString: DATABASE_URL, 24 | }); 25 | 26 | const adapter = new PrismaPg(pool); 27 | 28 | super({ adapter }); 29 | } 30 | 31 | async onModuleInit() { 32 | await this.$connect(); 33 | } 34 | 35 | async onModuleDestroy() { 36 | await this.$disconnect(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noUnusedLocals": true, 4 | "noUnusedParameters": true, 5 | "useUnknownInCatchVariables": false, 6 | "lib": ["ES2021"], 7 | "allowJs": true, 8 | "strict": true, 9 | "alwaysStrict": true, 10 | "strictNullChecks": true, 11 | "noImplicitAny": true, 12 | "module": "CommonJS", 13 | "skipLibCheck": true, 14 | "resolveJsonModule": true, 15 | "esModuleInterop": true, 16 | "moduleResolution": "Node", 17 | "declaration": true, 18 | "removeComments": true, 19 | "emitDecoratorMetadata": true, 20 | "experimentalDecorators": true, 21 | "allowSyntheticDefaultImports": true, 22 | "target": "ESNext", 23 | "sourceMap": true, 24 | "baseUrl": "./", 25 | "paths": { 26 | "@/*": ["src/*"] 27 | }, 28 | "outDir": "dist", 29 | "noImplicitThis": true, 30 | "incremental": true, 31 | "strictBindCallApply": true, 32 | "noPropertyAccessFromIndexSignature": false, 33 | "noUncheckedIndexedAccess": true, 34 | "noImplicitReturns": true, 35 | "strictFunctionTypes": true, 36 | "strictPropertyInitialization": false, 37 | "forceConsistentCasingInFileNames": false, 38 | "noFallthroughCasesInSwitch": false, 39 | "allowUnreachableCode": false, 40 | "allowUnusedLabels": false, 41 | "noImplicitUseStrict": false, 42 | "suppressExcessPropertyErrors": false, 43 | "suppressImplicitAnyIndexErrors": false, 44 | "noStrictGenericChecks": false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/common/prisma-studio/prisma-studio.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | All, 3 | Body, 4 | Controller, 5 | HttpStatus, 6 | Logger, 7 | Req, 8 | Res, 9 | } from '@nestjs/common'; 10 | import { ApiExcludeEndpoint } from '@nestjs/swagger'; 11 | import { FastifyReply, FastifyRequest } from 'fastify'; 12 | import { PrismaStudioService } from '@/common/prisma-studio/prisma-studio.service'; 13 | 14 | const STUDIO_ROUTES = [ 15 | 'studio', 16 | 'studio/*', 17 | 'bff', 18 | 'bff/*', 19 | 'telemetry', 20 | 'api', 21 | 'api/*', 22 | 'ui', 23 | 'ui/*', 24 | 'data', 25 | 'data/*', 26 | 'assets', 27 | 'assets/*', 28 | 'favicon.ico', 29 | ':file(.*\\.(?:js|css|png|svg|ico|map|woff2))', 30 | ]; 31 | 32 | @Controller() 33 | export class PrismaStudioController { 34 | private readonly logger = new Logger(PrismaStudioController.name); 35 | 36 | constructor(private readonly prismaStudioService: PrismaStudioService) {} 37 | 38 | @ApiExcludeEndpoint() 39 | @All(STUDIO_ROUTES) 40 | async proxy( 41 | @Req() request: FastifyRequest, 42 | @Res() response: FastifyReply, 43 | @Body() body: unknown, 44 | ): Promise { 45 | try { 46 | await this.prismaStudioService.processRequest(request, response, body); 47 | } catch (error) { 48 | if (!response.sent) { 49 | response 50 | .code(HttpStatus.INTERNAL_SERVER_ERROR) 51 | .send('An error occurred while processing your request'); 52 | } 53 | this.logger.error('Error processing Studio request:', error); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/common/logger-serve/auth/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { ConfigService } from '@nestjs/config'; 8 | import { FastifyReply, FastifyRequest } from 'fastify'; 9 | 10 | @Injectable() 11 | export class AuthGuard implements CanActivate { 12 | constructor(private readonly configService: ConfigService) {} 13 | 14 | canActivate(context: ExecutionContext): boolean { 15 | const request = context.switchToHttp().getRequest(); 16 | const response = context.switchToHttp().getResponse(); 17 | 18 | const authHeader = request.headers.authorization; 19 | if (!authHeader || !authHeader.startsWith('Basic ')) { 20 | this.requestCredentials(response); 21 | throw new UnauthorizedException('Missing Authorization header'); 22 | } 23 | 24 | const base64Credentials = authHeader.split(' ')[1]; 25 | const credentials = Buffer.from(base64Credentials!, 'base64').toString( 26 | 'ascii', 27 | ); 28 | const [username, password] = credentials.split(':'); 29 | 30 | const expectedUsername = this.configService.getOrThrow( 31 | 'LOGS_ADMIN_PANEL_USER', 32 | ); 33 | const expectedPassword = this.configService.getOrThrow( 34 | 'LOGS_ADMIN_PANEL_PASSWORD', 35 | ); 36 | 37 | if (username !== expectedUsername || password !== expectedPassword) { 38 | this.requestCredentials(response); 39 | throw new UnauthorizedException('Invalid credentials'); 40 | } 41 | 42 | return true; 43 | } 44 | 45 | private requestCredentials(response: FastifyReply): void { 46 | response.header( 47 | 'WWW-Authenticate', 48 | 'Basic realm="Access to the protected resource"', 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/common/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { passportJwtSecret } from 'jwks-rsa'; 5 | import { ExtractJwt, Strategy } from 'passport-jwt'; 6 | import { Profile } from '@/app/profile/types/profile.object-type'; 7 | import { PrismaService } from '@/common/prisma/prisma.service'; 8 | 9 | interface JwtPayload { 10 | sub: string; 11 | iss: string; 12 | aud: string; 13 | scope?: string; 14 | roles?: string[]; 15 | [key: string]: unknown; 16 | } 17 | 18 | @Injectable() 19 | export class JwtStrategy extends PassportStrategy(Strategy) { 20 | constructor( 21 | configService: ConfigService, 22 | private readonly prisma: PrismaService, 23 | ) { 24 | const issuer = configService.getOrThrow('OIDC_ISSUER'); 25 | const audience = configService.getOrThrow('OIDC_AUDIENCE'); 26 | const jwksUri = configService.getOrThrow('OIDC_JWKS_URI'); 27 | 28 | super({ 29 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 30 | ignoreExpiration: false, 31 | audience: audience, 32 | issuer: issuer, 33 | algorithms: ['RS256', 'ES384'], 34 | secretOrKeyProvider: passportJwtSecret({ 35 | cache: true, 36 | rateLimit: true, 37 | jwksRequestsPerMinute: 5, 38 | jwksUri: jwksUri, 39 | }), 40 | }); 41 | } 42 | 43 | async validate(payload: JwtPayload): Promise { 44 | if (!payload.sub) { 45 | throw new UnauthorizedException('Token has no subject (sub)'); 46 | } 47 | 48 | const oidcSub = payload.sub; 49 | 50 | return this.prisma.profile.upsert({ 51 | where: { oidcSub }, 52 | create: { 53 | oidcSub, 54 | }, 55 | update: {}, 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/common/dotenv-validator/dotenv-validator.service.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | @Injectable() 5 | export class DotenvValidatorService { 6 | constructor() { 7 | if (process.env.NODE_ENV === 'production') { 8 | return; 9 | } 10 | 11 | const environmentFilePath = '.env'; 12 | const environmentExampleFilePath = '.env.example'; 13 | 14 | const environmentContent = fs.readFileSync(environmentFilePath, 'utf8'); 15 | const environmentKeys = this.parseKeys(environmentContent); 16 | 17 | const environmentExampleContent = fs.readFileSync( 18 | environmentExampleFilePath, 19 | 'utf8', 20 | ); 21 | const environmentExampleKeys = this.parseKeys(environmentExampleContent); 22 | 23 | for (const key of environmentExampleKeys) { 24 | if (!environmentKeys.includes(key)) { 25 | throw new Error( 26 | `Environment variable "${key}" exists in ${environmentExampleFilePath} but not in ${environmentFilePath}`, 27 | ); 28 | } 29 | } 30 | 31 | for (const key of environmentKeys) { 32 | if (!environmentExampleKeys.includes(key)) { 33 | throw new Error( 34 | `Environment variable "${key}" exists in ${environmentFilePath} but not in ${environmentExampleFilePath}`, 35 | ); 36 | } 37 | } 38 | } 39 | 40 | /** 41 | * Simple parser to extract keys from env file content 42 | * Ignores comments (#) and empty lines 43 | */ 44 | private parseKeys(content: string): string[] { 45 | return content 46 | .split('\n') 47 | .map((line) => line.trim()) 48 | .filter((line) => line.length > 0 && !line.startsWith('#')) 49 | .map((line) => { 50 | const separatorIndex = line.indexOf('='); 51 | if (separatorIndex === -1) return line; 52 | return line.substring(0, separatorIndex).trim(); 53 | }); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/common/auth/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | Injectable, 4 | UnauthorizedException, 5 | } from '@nestjs/common'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import { GqlExecutionContext } from '@nestjs/graphql'; 8 | import { AuthGuard } from '@nestjs/passport'; 9 | import { ProfileRole } from '@/@generated/prisma/enums'; 10 | import { PrismaService } from '@/common/prisma/prisma.service'; 11 | 12 | @Injectable() 13 | export class JwtAuthGuard extends AuthGuard('jwt') { 14 | constructor( 15 | private readonly configService: ConfigService, 16 | private readonly prisma: PrismaService, 17 | ) { 18 | super(); 19 | } 20 | 21 | async canActivate(context: ExecutionContext): Promise { 22 | const isMockEnabled = 23 | this.configService.get('OIDC_MOCK_ENABLED') === 'true'; 24 | 25 | if (isMockEnabled) { 26 | const request = this.getRequest(context); 27 | 28 | request.user = await this.prisma.profile.upsert({ 29 | where: { oidcSub: 'mock-oidc-sub' }, 30 | create: { 31 | oidcSub: 'mock-oidc-sub', 32 | roles: [ProfileRole.USER, ProfileRole.ADMIN], 33 | avatarUrl: 'https://mock.jpg', 34 | }, 35 | update: { 36 | roles: [ProfileRole.USER, ProfileRole.ADMIN], 37 | avatarUrl: 'https://mock.jpg', 38 | }, 39 | }); 40 | return true; 41 | } 42 | 43 | return super.canActivate(context) as Promise; 44 | } 45 | 46 | getRequest(context: ExecutionContext) { 47 | // 1. HTTP REST 48 | if (context.getType() === 'http') { 49 | return context.switchToHttp().getRequest(); 50 | } 51 | // 2. GraphQL 52 | const ctx = GqlExecutionContext.create(context); 53 | const gqlContext = ctx.getContext(); 54 | 55 | if (gqlContext.req) { 56 | return gqlContext.req; 57 | } 58 | 59 | throw new UnauthorizedException('Cannot determine request context'); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/app/profile/profile.resolver.ts: -------------------------------------------------------------------------------- 1 | import { UseGuards } from '@nestjs/common'; 2 | import { 3 | Args, 4 | Context, 5 | Mutation, 6 | Query, 7 | Resolver, 8 | Subscription, 9 | } from '@nestjs/graphql'; 10 | import { PubSub } from 'mercurius'; 11 | import { ProfileService } from '@/app/profile/profile.service'; 12 | import { Profile } from '@/app/profile/types/profile.object-type'; 13 | import { ProfileUpdateInput } from '@/app/profile/types/profile-update.input'; 14 | import { 15 | CurrentUser, 16 | CurrentUserType, 17 | } from '@/common/auth/current-user.decorator'; 18 | import { JwtAuthGuard } from '@/common/auth/jwt-auth.guard'; 19 | import { RolesGuard } from '@/common/auth/roles.guard'; 20 | 21 | const EVENTS = { 22 | PROFILE_UPDATED: 'profileUpdated', 23 | }; 24 | 25 | @UseGuards(JwtAuthGuard, RolesGuard) 26 | @Resolver(() => Profile) 27 | export class ProfileResolver { 28 | constructor(private readonly profileService: ProfileService) {} 29 | 30 | @Query(() => Profile, { name: 'me' }) 31 | async me(@CurrentUser() user: CurrentUserType): Promise { 32 | return user; 33 | } 34 | 35 | @Mutation(() => Profile) 36 | async updateProfile( 37 | @CurrentUser() user: CurrentUserType, 38 | @Args('input') input: ProfileUpdateInput, 39 | @Context('pubsub') pubSub: PubSub, 40 | ): Promise { 41 | const updatedProfile = await this.profileService.updateProfile( 42 | user.id, 43 | input, 44 | ); 45 | 46 | pubSub.publish({ 47 | topic: EVENTS.PROFILE_UPDATED, 48 | payload: { 49 | profileUpdated: updatedProfile, 50 | }, 51 | }); 52 | 53 | return updatedProfile; 54 | } 55 | 56 | @Subscription(() => Profile, { 57 | name: EVENTS.PROFILE_UPDATED, 58 | filter: (payload, _variables, context) => { 59 | const updatedProfileId = payload.profileUpdated.id; 60 | const currentUserId = context.req?.user?.id; 61 | return currentUserId === updatedProfileId; 62 | }, 63 | }) 64 | profileUpdated(@Context('pubsub') pubSub: PubSub) { 65 | return pubSub.subscribe(EVENTS.PROFILE_UPDATED); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/common/git-commit-saver.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; 3 | import path from 'node:path'; 4 | 5 | const DIST_FOLDER_PATH = path.resolve(process.cwd(), 'dist'); 6 | const LAST_COMMIT_INFO_FILE_PATH = path.resolve( 7 | DIST_FOLDER_PATH, 8 | 'last-commit-info.json', 9 | ); 10 | 11 | interface CommitInfo { 12 | name: string; 13 | hash: string; 14 | } 15 | 16 | // Check if Git is installed 17 | function isGitInstalled(): boolean { 18 | try { 19 | execSync('git --version'); 20 | return true; 21 | } catch { 22 | console.error('Git is not installed.'); 23 | return false; 24 | } 25 | } 26 | 27 | // Check if the current directory is inside a Git repository 28 | function isGitRepo(): boolean { 29 | if (!isGitInstalled()) return false; 30 | 31 | try { 32 | execSync('git rev-parse --is-inside-work-tree'); 33 | return true; 34 | } catch { 35 | console.error('Not a Git repository.'); 36 | return false; 37 | } 38 | } 39 | 40 | // Get the last commit info 41 | function getLastCommitInfo(): CommitInfo { 42 | if (!isGitRepo()) return { name: 'NO_COMMIT_NAME', hash: 'NO_COMMIT_HASH' }; 43 | 44 | try { 45 | const name = execSync('git log -1 --pretty=format:%s').toString().trim(); 46 | const hash = execSync('git rev-parse HEAD').toString().trim(); 47 | return { name, hash }; 48 | } catch (error) { 49 | console.error('Error fetching commit info:', error); 50 | return { name: 'NO_COMMIT_NAME', hash: 'NO_COMMIT_HASH' }; 51 | } 52 | } 53 | 54 | // Ensure the dist directory exists 55 | function ensureDistributionFolderExists(): void { 56 | if (!existsSync(DIST_FOLDER_PATH)) { 57 | mkdirSync(DIST_FOLDER_PATH); 58 | console.log('Dist folder created at:', DIST_FOLDER_PATH); 59 | } 60 | } 61 | 62 | // Save the last commit info to a JSON file 63 | function saveCommitInfo(): void { 64 | const lastCommitInfo = getLastCommitInfo(); 65 | writeFileSync(LAST_COMMIT_INFO_FILE_PATH, JSON.stringify(lastCommitInfo)); 66 | console.log('Last commit info saved to:', LAST_COMMIT_INFO_FILE_PATH); 67 | } 68 | 69 | // Main execution flow 70 | ensureDistributionFolderExists(); 71 | saveCommitInfo(); 72 | -------------------------------------------------------------------------------- /src/common/logger/pino-config.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'node:http'; 2 | import path from 'node:path'; 3 | import * as process from 'node:process'; 4 | import { Params } from 'nestjs-pino'; 5 | import { TransportTargetOptions } from 'pino'; 6 | 7 | const LOG_DIR = path.join(process.cwd(), 'data', 'logs'); 8 | const IS_DEV = process.env.NODE_ENV !== 'production'; 9 | 10 | const fileTransport = (filename: string): TransportTargetOptions => ({ 11 | target: 'pino-roll', 12 | options: { 13 | file: path.join(LOG_DIR, filename), 14 | frequency: 'daily', 15 | mkdir: true, 16 | size: '20m', 17 | limit: { 18 | count: 10, 19 | }, 20 | }, 21 | }); 22 | 23 | export const pinoConfig: Params = { 24 | pinoHttp: { 25 | level: IS_DEV ? 'trace' : 'info', 26 | customSuccessMessage: (_req, _res, responseTime) => { 27 | return `Request completed in ${Math.round(responseTime)}ms`; 28 | }, 29 | autoLogging: { 30 | ignore: (_req) => { 31 | const req = _req as IncomingMessage & { originalUrl?: string }; 32 | const url = req.originalUrl || req.url || ''; 33 | 34 | if (url?.includes('/health')) return true; 35 | 36 | if (url?.includes('/favicon.ico')) return true; 37 | 38 | if (url?.startsWith('/logs')) return true; 39 | 40 | return false; 41 | }, 42 | }, 43 | redact: { 44 | paths: ['req.headers.authorization', 'req.body.password'], 45 | remove: true, 46 | }, 47 | transport: { 48 | targets: [ 49 | { 50 | target: 'pino-pretty', 51 | options: { 52 | colorize: true, 53 | singleLine: true, 54 | translateTime: 'yyyy-mm-dd HH:MM:ss', 55 | ignore: 'pid,hostname', 56 | }, 57 | level: 'trace', 58 | }, 59 | { 60 | ...fileTransport('all/log'), 61 | level: 'trace', 62 | }, 63 | { 64 | ...fileTransport('error/log'), 65 | level: 'error', 66 | }, 67 | ], 68 | }, 69 | serializers: { 70 | req: (req) => ({ 71 | id: req.id, 72 | method: req.method, 73 | url: req.url, 74 | ip: req.remoteAddress || req.socket?.remoteAddress, 75 | }), 76 | }, 77 | }, 78 | }; 79 | -------------------------------------------------------------------------------- /src/app/health/health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpStatus, Logger, Res } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { FastifyReply } from 'fastify'; 4 | import Redis from 'ioredis'; 5 | import { PrismaService } from '@/common/prisma/prisma.service'; 6 | 7 | @Controller('health') 8 | export class HealthController { 9 | private readonly logger: Logger = new Logger(HealthController.name); 10 | private readonly client: Redis; 11 | 12 | constructor( 13 | private readonly prisma: PrismaService, 14 | private readonly configService: ConfigService, 15 | ) { 16 | const redisHost = this.configService.get('REDIS_HOST'); 17 | const redisPort = this.configService.get('REDIS_PORT'); 18 | const redisPassword = this.configService.get('REDIS_PASSWORD'); 19 | const redisUrl = `redis://${redisHost}:${redisPort}?password=${redisPassword}`; 20 | this.client = new Redis(redisUrl); 21 | } 22 | 23 | @Get() 24 | async getHealth(@Res() response: FastifyReply): Promise { 25 | const [databaseOnline, redisOnline] = await Promise.all([ 26 | this.checkDatabase(), 27 | this.checkRedis(), 28 | ]); 29 | 30 | const status = 31 | databaseOnline && redisOnline 32 | ? HttpStatus.OK 33 | : HttpStatus.SERVICE_UNAVAILABLE; 34 | 35 | response.code(status).send({ 36 | status: status === HttpStatus.OK ? 'ok' : 'error', 37 | info: { 38 | serverTime: new Date().toISOString(), 39 | database: { status: databaseOnline ? 'ok' : 'error' }, 40 | redis: { status: redisOnline ? 'ok' : 'error' }, 41 | }, 42 | }); 43 | } 44 | 45 | private async checkDatabase(): Promise { 46 | try { 47 | await this.prisma.$executeRaw`SELECT 1`; 48 | return true; 49 | } catch (error) { 50 | this.logger.error('Database is offline', error); 51 | return false; 52 | } 53 | } 54 | 55 | private async checkRedis(): Promise { 56 | try { 57 | const pong = await this.client.ping(); 58 | if (pong !== 'PONG') { 59 | this.logger.error('Redis is offline'); 60 | return false; 61 | } 62 | return true; 63 | } catch (error) { 64 | this.logger.error('Error pinging Redis', error); 65 | return false; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/common/bull-board/bull-board.module.ts: -------------------------------------------------------------------------------- 1 | import { BullMQAdapter } from '@bull-board/api/bullMQAdapter'; 2 | import { FastifyAdapter } from '@bull-board/fastify'; 3 | import { BullBoardModule as BullBoard } from '@bull-board/nestjs'; 4 | import { Module, OnModuleInit } from '@nestjs/common'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { HttpAdapterHost } from '@nestjs/core'; 7 | import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; 8 | 9 | @Module({ 10 | imports: [ 11 | BullBoard.forRoot({ 12 | route: '/board', 13 | adapter: FastifyAdapter, 14 | }), 15 | BullBoard.forFeature({ 16 | name: 'test', 17 | adapter: BullMQAdapter, 18 | }), 19 | ], 20 | }) 21 | export class BullBoardModule implements OnModuleInit { 22 | constructor( 23 | private readonly adapterHost: HttpAdapterHost, 24 | private readonly configService: ConfigService, 25 | ) {} 26 | 27 | onModuleInit() { 28 | const httpAdapter = this.adapterHost.httpAdapter; 29 | const fastify = httpAdapter.getInstance(); 30 | 31 | const login = this.configService.get('BULL_BOARD_LOGIN'); 32 | const password = this.configService.get('BULL_BOARD_PASSWORD'); 33 | 34 | fastify.addHook( 35 | 'onRequest', 36 | async (req: FastifyRequest, reply: FastifyReply) => { 37 | if (!req.url.startsWith('/board')) { 38 | return; 39 | } 40 | 41 | const unauthorized = () => { 42 | reply 43 | .code(401) 44 | .header('WWW-Authenticate', 'Basic realm="BullBoard"') 45 | .send('Unauthorized'); 46 | return reply; 47 | }; 48 | 49 | if (!login || !password) { 50 | return unauthorized(); 51 | } 52 | 53 | const authHeader = req.headers.authorization; 54 | 55 | if (!authHeader) { 56 | return unauthorized(); 57 | } 58 | 59 | const [scheme, credentials] = authHeader.split(' '); 60 | 61 | if (scheme !== 'Basic' || !credentials) { 62 | return unauthorized(); 63 | } 64 | 65 | const [user, pass] = Buffer.from(credentials, 'base64') 66 | .toString() 67 | .split(':'); 68 | 69 | if (user !== login || pass !== password) { 70 | reply.code(403).send('Forbidden'); 71 | return reply; 72 | } 73 | }, 74 | ); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/app/file-upload/file-upload.controller.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { 3 | BadRequestException, 4 | Controller, 5 | Get, 6 | Param, 7 | Post, 8 | Req, 9 | Res, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { 13 | ApiBearerAuth, 14 | ApiConsumes, 15 | ApiOperation, 16 | ApiParam, 17 | ApiResponse, 18 | } from '@nestjs/swagger'; 19 | import { FastifyReply, FastifyRequest } from 'fastify'; 20 | import { FileUploadService } from '@/app/file-upload/file-upload.service'; 21 | import { JwtAuthGuard } from '@/common/auth/jwt-auth.guard'; 22 | import { RealIp } from '@/common/real-ip/real-ip.decorator'; 23 | 24 | @Controller() 25 | export class FileUploadController { 26 | constructor(private readonly fileUploadService: FileUploadService) {} 27 | 28 | @Post('upload') 29 | @UseGuards(JwtAuthGuard) 30 | @ApiBearerAuth() 31 | @ApiOperation({ summary: 'Upload files' }) 32 | @ApiConsumes('multipart/form-data') 33 | @ApiResponse({ status: 200, description: 'Files uploaded successfully.' }) 34 | @ApiResponse({ status: 400, description: 'Bad request.' }) 35 | @ApiResponse({ status: 401, description: 'Unauthorized.' }) 36 | async uploadFile(@Req() req: FastifyRequest, @RealIp() ip: string) { 37 | if (!req.isMultipart()) { 38 | throw new BadRequestException('Request is not multipart'); 39 | } 40 | 41 | const savedFiles = []; 42 | const parts = req.parts(); 43 | 44 | for await (const part of parts) { 45 | if (part.type === 'file') { 46 | const fileData = await this.fileUploadService.processFile(part); 47 | if (fileData) { 48 | savedFiles.push(fileData); 49 | } 50 | } 51 | } 52 | 53 | if (savedFiles.length === 0) { 54 | throw new BadRequestException('No valid files uploaded'); 55 | } 56 | 57 | await this.fileUploadService.saveMetadata(savedFiles, ip ?? 'unknown'); 58 | 59 | return savedFiles.map((f) => ({ 60 | filename: f.filename, 61 | path: f.path, 62 | })); 63 | } 64 | 65 | @Get('/uploads/*') 66 | @ApiOperation({ summary: 'Get file' }) 67 | @ApiParam({ name: '*', required: true, description: 'The file path.' }) 68 | @ApiResponse({ status: 200, description: 'File retrieved.' }) 69 | @ApiResponse({ status: 404, description: 'File not found.' }) 70 | async getFile( 71 | @Param('*') filePathParam: string, 72 | @Res() response: FastifyReply, 73 | ) { 74 | const { fullPath, mimeType } = 75 | this.fileUploadService.getSafeFileInfo(filePathParam); 76 | 77 | response.type(mimeType); 78 | response.send(fs.createReadStream(fullPath)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import compression from '@fastify/compress'; 2 | import helmet from '@fastify/helmet'; 3 | import multiPart from '@fastify/multipart'; 4 | import rateLimit from '@fastify/rate-limit'; 5 | import { ConfigService } from '@nestjs/config'; 6 | import { NestFactory } from '@nestjs/core'; 7 | import { 8 | FastifyAdapter, 9 | NestFastifyApplication, 10 | } from '@nestjs/platform-fastify'; 11 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 12 | import { AltairFastify } from 'altair-fastify-plugin'; 13 | import { Logger } from 'nestjs-pino'; 14 | import { cleanupOpenApiDoc, ZodValidationPipe } from 'nestjs-zod'; 15 | import packageJson from '../package.json'; 16 | import { AppModule } from './app/app.module'; 17 | 18 | async function bootstrap(): Promise { 19 | const adapter = new FastifyAdapter({ 20 | logger: false, 21 | bodyLimit: 10485760, 22 | trustProxy: true, 23 | }); 24 | 25 | const app = await NestFactory.create( 26 | AppModule, 27 | adapter, 28 | { 29 | bufferLogs: true, 30 | }, 31 | ); 32 | 33 | app.useLogger(app.get(Logger)); 34 | 35 | app.useGlobalPipes(new ZodValidationPipe()); 36 | 37 | await app.register(multiPart); 38 | 39 | await app.register(AltairFastify, { 40 | path: '/altair', 41 | baseURL: '/altair/', 42 | endpointURL: '/graphql', 43 | }); 44 | 45 | await app.register(helmet, { 46 | contentSecurityPolicy: false, 47 | crossOriginEmbedderPolicy: false, 48 | crossOriginOpenerPolicy: false, 49 | crossOriginResourcePolicy: false, 50 | }); 51 | 52 | await app.register(rateLimit, { 53 | max: 100, 54 | timeWindow: '1 minute', 55 | }); 56 | 57 | await app.register(compression, { 58 | encodings: ['gzip', 'deflate'], 59 | threshold: 1024, 60 | }); 61 | 62 | const configService = app.get(ConfigService); 63 | app.enableShutdownHooks(); 64 | 65 | // Swagger setup 66 | const swaggerConfig = new DocumentBuilder() 67 | .setTitle(packageJson.name) 68 | .setDescription(`${packageJson.name} REST API documentation`) 69 | .setVersion(packageJson.version) 70 | .build(); 71 | 72 | const document = SwaggerModule.createDocument(app, swaggerConfig); 73 | SwaggerModule.setup('swagger', app, cleanupOpenApiDoc(document)); 74 | 75 | // Enable CORS 76 | app.enableCors(); 77 | 78 | const port = configService.getOrThrow('PORT'); 79 | await app.listen(port, '0.0.0.0'); 80 | 81 | const logger = app.get(Logger); 82 | logger.log(`App started at http://localhost:${port}`); 83 | logger.log(`Altair at http://localhost:${port}/altair`); 84 | } 85 | 86 | bootstrap().catch((error) => { 87 | console.error('Application failed to start', error); 88 | }); 89 | -------------------------------------------------------------------------------- /src/common/graphql/error-formatter.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Logger } from '@nestjs/common'; 2 | import { FastifyRequest } from 'fastify'; 3 | import { ExecutionResult } from 'graphql'; 4 | import { GraphQLError } from 'graphql/error'; 5 | import { MercuriusContext } from 'mercurius'; 6 | import { ZodValidationException } from 'nestjs-zod'; 7 | import { ZodError } from 'zod'; 8 | 9 | const logger = new Logger('GraphQLErrorFormatter'); 10 | 11 | export function gqlErrorFormatter( 12 | execution: ExecutionResult, 13 | context: MercuriusContext, 14 | ): { statusCode: number; response: ExecutionResult } { 15 | const errors = execution.errors; 16 | 17 | if (!errors || errors.length === 0) { 18 | return { statusCode: 200, response: execution }; 19 | } 20 | 21 | const req = (context as MercuriusContext & { req?: FastifyRequest }).req; 22 | const requestId = req?.id || 'unknown'; 23 | 24 | const formattedErrors = errors.map((error) => { 25 | const originalError = error.originalError; 26 | 27 | if (originalError instanceof ZodValidationException) { 28 | const issues = (originalError.getZodError() as ZodError).issues; 29 | return { 30 | message: 'Validation Failed', 31 | locations: error.locations, 32 | path: error.path, 33 | extensions: { 34 | code: 'BAD_USER_INPUT', 35 | requestId, 36 | details: issues.map((i) => ({ 37 | field: i.path.join('.'), 38 | message: i.message, 39 | })), 40 | }, 41 | }; 42 | } 43 | 44 | if (originalError instanceof HttpException) { 45 | const status = originalError.getStatus(); 46 | let code = 'INTERNAL_SERVER_ERROR'; 47 | 48 | if (status === HttpStatus.BAD_REQUEST) code = 'BAD_USER_INPUT'; 49 | if (status === HttpStatus.UNAUTHORIZED) code = 'UNAUTHENTICATED'; 50 | if (status === HttpStatus.FORBIDDEN) code = 'FORBIDDEN'; 51 | if (status === HttpStatus.NOT_FOUND) code = 'NOT_FOUND'; 52 | 53 | const response = originalError.getResponse(); 54 | const details = typeof response === 'object' ? response : null; 55 | 56 | return { 57 | message: originalError.message, 58 | locations: error.locations, 59 | path: error.path, 60 | extensions: { 61 | code, 62 | requestId, 63 | details, 64 | }, 65 | }; 66 | } 67 | 68 | logger.error({ 69 | msg: 'GraphQL Internal Error', 70 | err: originalError || error, 71 | requestId, 72 | }); 73 | 74 | return { 75 | message: 'Internal Server Error', 76 | locations: error.locations, 77 | path: error.path, 78 | extensions: { 79 | code: 'INTERNAL_SERVER_ERROR', 80 | requestId, 81 | }, 82 | }; 83 | }); 84 | 85 | return { 86 | statusCode: 200, 87 | response: { 88 | data: execution.data, 89 | errors: formattedErrors as unknown as GraphQLError[], 90 | }, 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/common/all-exceptions-filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | HttpStatus, 7 | Logger, 8 | } from '@nestjs/common'; 9 | import { FastifyReply, FastifyRequest } from 'fastify'; 10 | import { ZodValidationException } from 'nestjs-zod'; 11 | import { ZodError } from 'zod'; 12 | 13 | @Catch() 14 | export class AllExceptionsFilter implements ExceptionFilter { 15 | private readonly logger = new Logger(AllExceptionsFilter.name); 16 | 17 | catch(exception: unknown, host: ArgumentsHost): void { 18 | if (host.getType() !== 'http') { 19 | return; 20 | } 21 | 22 | const ctx = host.switchToHttp(); 23 | const response = ctx.getResponse(); 24 | const request = ctx.getRequest(); 25 | 26 | if (response.sent) return; 27 | 28 | const status = this.getHttpStatus(exception); 29 | const errorBody = this.buildErrorBody(exception, status, request.id); 30 | 31 | if (status >= 500) { 32 | this.logger.error({ 33 | msg: 'HTTP Error', 34 | err: exception, 35 | requestId: request.id, 36 | }); 37 | } else { 38 | this.logger.warn({ 39 | msg: 'Client Error', 40 | statusCode: status, 41 | requestId: request.id, 42 | error: errorBody.message, 43 | }); 44 | } 45 | 46 | response.status(status).send(errorBody); 47 | } 48 | 49 | private getHttpStatus(exception: unknown): number { 50 | if (exception instanceof ZodValidationException) 51 | return HttpStatus.BAD_REQUEST; 52 | if (exception instanceof HttpException) return exception.getStatus(); 53 | return HttpStatus.INTERNAL_SERVER_ERROR; 54 | } 55 | 56 | private buildErrorBody( 57 | exception: unknown, 58 | status: number, 59 | requestId: string, 60 | ) { 61 | const base = { 62 | statusCode: status, 63 | timestamp: new Date().toISOString(), 64 | requestId, 65 | }; 66 | 67 | if (exception instanceof ZodValidationException) { 68 | const zodError = exception.getZodError() as unknown as ZodError; 69 | return { 70 | ...base, 71 | error: 'Validation Failed', 72 | message: 'Input validation error', 73 | details: zodError.issues.map((issue) => ({ 74 | field: issue.path.join('.'), 75 | message: issue.message, 76 | code: issue.code, 77 | })), 78 | }; 79 | } 80 | 81 | if (exception instanceof HttpException) { 82 | const res = exception.getResponse(); 83 | const message = 84 | typeof res === 'object' && res !== null && 'message' in res 85 | ? (res as HttpException).message 86 | : exception.message; 87 | 88 | return { 89 | ...base, 90 | error: exception.name, 91 | message, 92 | }; 93 | } 94 | 95 | return { 96 | ...base, 97 | error: 'Internal Server Error', 98 | message: 'An unexpected error occurred', 99 | }; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/db-backup-tool/restore.ts: -------------------------------------------------------------------------------- 1 | import * as childProcess from 'node:child_process'; 2 | import * as fs from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | 5 | import { Logger } from './logger'; 6 | 7 | const logger = new Logger({ name: 'Restore' }); 8 | 9 | // Environment variables with strict types 10 | interface EnvironmentVariables { 11 | DATABASE_HOST: string; 12 | DATABASE_PORT: string; 13 | DATABASE_USER: string; 14 | DATABASE_PASSWORD: string; 15 | DATABASE_NAME: string; 16 | BACKUP_DIR: string; 17 | } 18 | 19 | const environment: EnvironmentVariables = { 20 | DATABASE_HOST: process.env.DATABASE_HOST || 'localhost', 21 | DATABASE_PORT: process.env.DATABASE_PORT || '5432', 22 | DATABASE_USER: process.env.DATABASE_USER || 'postgres', 23 | DATABASE_PASSWORD: process.env.DATABASE_PASSWORD || 'postgres', 24 | DATABASE_NAME: process.env.DATABASE_NAME || 'postgres', 25 | BACKUP_DIR: process.env.BACKUP_DIR || './data/database_backups', 26 | }; 27 | 28 | // Safe logging of environment variables 29 | const safeEnvironment: EnvironmentVariables = { 30 | ...environment, 31 | DATABASE_PASSWORD: '***', 32 | }; 33 | 34 | logger.info('Environment variables:', safeEnvironment); 35 | 36 | // Function to restore a backup 37 | async function restoreBackup(backupFileName: string): Promise { 38 | const backupFilePath: string = path.join( 39 | environment.BACKUP_DIR, 40 | backupFileName, 41 | ); 42 | 43 | try { 44 | await fs.access(backupFilePath); 45 | } catch { 46 | logger.error(`Backup file not found: ${backupFilePath}`); 47 | throw new Error('Backup file not found'); 48 | } 49 | 50 | const isCompressed: boolean = backupFileName.endsWith('.gz'); 51 | 52 | // Command for restoration 53 | const restoreCommand: string = isCompressed 54 | ? `gunzip -c ${backupFilePath} | psql -h ${environment.DATABASE_HOST} -p ${environment.DATABASE_PORT} -U ${environment.DATABASE_USER} -d ${environment.DATABASE_NAME}` 55 | : `psql -h ${environment.DATABASE_HOST} -p ${environment.DATABASE_PORT} -U ${environment.DATABASE_USER} -d ${environment.DATABASE_NAME} -f ${backupFilePath}`; 56 | 57 | logger.info(`Starting restore from: ${backupFilePath}`); 58 | await executeCommand(restoreCommand); 59 | } 60 | 61 | // Execute a shell command and handle errors 62 | async function executeCommand(command: string): Promise { 63 | return new Promise((resolve, reject) => { 64 | childProcess.exec( 65 | command, 66 | { env: { ...process.env, PGPASSWORD: environment.DATABASE_PASSWORD } }, 67 | (error) => { 68 | if (error) { 69 | logger.error('Command execution failed:', error); 70 | reject(error); 71 | } else { 72 | logger.info('Command executed successfully.'); 73 | resolve(); 74 | } 75 | }, 76 | ); 77 | }); 78 | } 79 | 80 | // Main function 81 | async function main(backupFileName: string): Promise { 82 | try { 83 | await restoreBackup(backupFileName); 84 | } catch (error) { 85 | logger.error('Fatal error in restore script:', error); 86 | throw error; 87 | } 88 | } 89 | 90 | // Example usage: tsx restore.ts backup_file_name.sql.gz 91 | const backupFileName = process.argv[2]; 92 | if (!backupFileName) { 93 | logger.error('Please provide the backup file name as an argument.'); 94 | throw new Error('Please provide the backup file name as an argument.'); 95 | } 96 | 97 | main(backupFileName).catch((error) => { 98 | logger.error('Fatal error in restore script:', error); 99 | throw error; 100 | }); 101 | -------------------------------------------------------------------------------- /src/common/prisma-studio/prisma-studio.service.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'node:child_process'; 2 | import { HttpStatus, Injectable, Logger } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { FastifyReply, FastifyRequest } from 'fastify'; 5 | import { ofetch } from 'ofetch'; 6 | 7 | @Injectable() 8 | export class PrismaStudioService { 9 | private readonly logger = new Logger(PrismaStudioService.name); 10 | 11 | constructor(private readonly configService: ConfigService) {} 12 | 13 | async startStudio(): Promise { 14 | return new Promise((resolve, reject) => { 15 | exec('npm run db:studio', (error, stdout, stderr) => { 16 | if (error) { 17 | this.logger.error('Command execution error:', error); 18 | return reject(error); 19 | } 20 | this.logger.log('Command execution stdout:', stdout); 21 | this.logger.error('Command execution stderr:', stderr); 22 | return resolve(); 23 | }); 24 | }); 25 | } 26 | 27 | async processRequest( 28 | request: FastifyRequest, 29 | response: FastifyReply, 30 | body?: unknown, 31 | ): Promise { 32 | const login = this.configService.getOrThrow('PRISMA_STUDIO_LOGIN'); 33 | const password = this.configService.getOrThrow( 34 | 'PRISMA_STUDIO_PASSWORD', 35 | ); 36 | const authHeader = request.headers.authorization; 37 | 38 | if (!authHeader) { 39 | this.logger.error('Unauthorized'); 40 | response 41 | .code(HttpStatus.UNAUTHORIZED) 42 | .header('WWW-Authenticate', 'Basic realm="Restricted"') 43 | .send('Unauthorized'); 44 | return; 45 | } 46 | 47 | const auth = authHeader.split(' ')[1]; 48 | if (!auth) { 49 | this.logger.error('Authorization header missing'); 50 | response.code(HttpStatus.UNAUTHORIZED).send('Unauthorized'); 51 | return; 52 | } 53 | 54 | const [authLogin, authPassword] = Buffer.from(auth, 'base64') 55 | .toString() 56 | .split(':'); 57 | 58 | if (authLogin !== login || authPassword !== password) { 59 | this.logger.error('Forbidden'); 60 | response.code(HttpStatus.FORBIDDEN).send('Forbidden'); 61 | return; 62 | } 63 | 64 | this.logger.log('Authorized'); 65 | 66 | const urlStr = request.url; 67 | const url = 68 | urlStr === '/studio' 69 | ? 'http://localhost:5555' 70 | : `http://localhost:5555${urlStr}`; 71 | 72 | try { 73 | const proxyResponse = await ofetch.raw(url, { 74 | method: request.method as string, 75 | headers: request.headers as Record, 76 | body: (request.method === 'POST' 77 | ? (body ?? request.body) 78 | : // biome-ignore lint/suspicious/noExplicitAny: ofetch body types are strict 79 | undefined) as any, 80 | ignoreResponseError: true, 81 | responseType: 'arrayBuffer', 82 | }); 83 | 84 | response.code(proxyResponse.status); 85 | 86 | proxyResponse.headers.forEach((value, key) => { 87 | const lowerKey = key.toLowerCase(); 88 | if (lowerKey === 'content-length' || lowerKey === 'content-encoding') { 89 | return; 90 | } 91 | response.header(key, value); 92 | }); 93 | 94 | if (proxyResponse._data) { 95 | response.send(Buffer.from(proxyResponse._data)); 96 | } else { 97 | response.send(); 98 | } 99 | } catch (error) { 100 | this.logger.error('Error while processing the request:', error); 101 | response 102 | .code(HttpStatus.INTERNAL_SERVER_ERROR) 103 | .send('Internal Server Error'); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/app/file-upload/file-upload.service.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'node:crypto'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import { pipeline } from 'node:stream/promises'; 5 | import { MultipartFile } from '@fastify/multipart'; 6 | import { 7 | ForbiddenException, 8 | Injectable, 9 | NotFoundException, 10 | } from '@nestjs/common'; 11 | import { lookup } from 'mrmime'; 12 | import { PrismaService } from '@/common/prisma/prisma.service'; 13 | 14 | @Injectable() 15 | export class FileUploadService { 16 | private readonly UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads'); 17 | private readonly DEFAULT_MIME_TYPE = 'application/octet-stream'; 18 | private readonly ALLOWED_MIME_TYPES = new Set([ 19 | 'image/png', 20 | 'image/jpeg', 21 | 'image/gif', 22 | 'image/svg+xml', 23 | 'image/webp', 24 | ]); 25 | 26 | constructor(private readonly prisma: PrismaService) {} 27 | 28 | getMimeType(filename: string): string { 29 | return lookup(filename) || this.DEFAULT_MIME_TYPE; 30 | } 31 | 32 | getSafeFileInfo(relativePath: string): { 33 | fullPath: string; 34 | mimeType: string; 35 | } { 36 | const fullPath = path.join(this.UPLOAD_DIR, relativePath); 37 | const resolvedPath = path.resolve(fullPath); 38 | const resolvedRoot = path.resolve(this.UPLOAD_DIR); 39 | 40 | if (!resolvedPath.startsWith(resolvedRoot)) { 41 | throw new ForbiddenException('Access denied'); 42 | } 43 | 44 | if (!fs.existsSync(resolvedPath)) { 45 | throw new NotFoundException('File not found'); 46 | } 47 | 48 | return { 49 | fullPath: resolvedPath, 50 | mimeType: this.getMimeType(resolvedPath), 51 | }; 52 | } 53 | 54 | async processFile(part: MultipartFile) { 55 | if (!this.ALLOWED_MIME_TYPES.has(part.mimetype)) { 56 | await part.toBuffer(); 57 | return null; 58 | } 59 | 60 | const { fullPath, relativeDir, filename, extension } = this.generatePaths( 61 | part.filename, 62 | ); 63 | 64 | await pipeline(part.file, fs.createWriteStream(fullPath)); 65 | const stats = fs.statSync(fullPath); 66 | 67 | return { 68 | filename, 69 | path: path.join('/uploads', relativeDir, filename), 70 | filepath: path.join(relativeDir, filename), 71 | originalFilename: part.filename, 72 | extension, 73 | size: stats.size, 74 | mimetype: part.mimetype, 75 | }; 76 | } 77 | 78 | // biome-ignore lint/suspicious/noExplicitAny: Because of Prisma 79 | async saveMetadata(files: Array, ip: string) { 80 | if (files.length === 0) return; 81 | 82 | await this.prisma.upload.createMany({ 83 | data: files.map((f) => ({ 84 | filepath: f.filepath, 85 | originalFilename: f.originalFilename, 86 | extension: f.extension, 87 | size: f.size, 88 | mimetype: f.mimetype, 89 | uploaderIp: ip, 90 | })), 91 | }); 92 | } 93 | 94 | private generatePaths(originalFilename: string) { 95 | const now = new Date(); 96 | const relativeDir = path.join( 97 | now.getFullYear().toString(), 98 | String(now.getMonth() + 1).padStart(2, '0'), 99 | String(now.getDate()).padStart(2, '0'), 100 | `${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}`, 101 | ); 102 | 103 | const fullDir = path.join(this.UPLOAD_DIR, relativeDir); 104 | 105 | if (!fs.existsSync(fullDir)) { 106 | fs.mkdirSync(fullDir, { recursive: true }); 107 | } 108 | 109 | const extension = path.extname(originalFilename); 110 | const filename = `${randomUUID()}${extension}`; 111 | 112 | return { 113 | fullPath: path.join(fullDir, filename), 114 | relativeDir, 115 | filename, 116 | extension, 117 | }; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-common-logging: &common-logging 2 | driver: "json-file" 3 | options: 4 | max-size: "50m" 5 | max-file: "5" 6 | 7 | x-common-restart-policy: &common-restart-policy 8 | restart: unless-stopped 9 | 10 | services: 11 | app: 12 | build: 13 | context: . 14 | dockerfile: Dockerfile 15 | <<: *common-restart-policy 16 | env_file: 17 | - .env 18 | ports: 19 | - "127.0.0.1:${PORT}:${PORT}" 20 | volumes: 21 | - ./data/uploads:/app/data/uploads 22 | - ./data/logs:/app/data/logs 23 | - ./data/database_backups:/app/data/database_backups 24 | networks: 25 | - liteend-net 26 | logging: *common-logging 27 | depends_on: 28 | db: 29 | condition: service_healthy 30 | redis: 31 | condition: service_healthy 32 | healthcheck: 33 | test: ["CMD-SHELL", "/app/healthcheck.sh"] 34 | interval: 5s 35 | timeout: 10s 36 | retries: 3 37 | start_period: 10s 38 | 39 | db: 40 | image: postgres:17-alpine 41 | <<: *common-restart-policy 42 | environment: 43 | POSTGRES_USER: ${DATABASE_USER} 44 | POSTGRES_PASSWORD: ${DATABASE_PASSWORD} 45 | POSTGRES_DB: ${DATABASE_NAME} 46 | PGDATA: "/var/lib/postgresql/data/pgdata" 47 | volumes: 48 | - ./data/postgres:/var/lib/postgresql/data/pgdata 49 | ports: 50 | - "127.0.0.1:${DATABASE_PORT}:${DATABASE_PORT}" 51 | command: -p ${DATABASE_PORT} 52 | networks: 53 | - liteend-net 54 | logging: *common-logging 55 | healthcheck: 56 | test: ["CMD-SHELL", "pg_isready -U ${DATABASE_USER} -d ${DATABASE_NAME} -p ${DATABASE_PORT}"] 57 | interval: 5s 58 | timeout: 5s 59 | retries: 5 60 | 61 | db_admin: 62 | image: dpage/pgadmin4:latest 63 | <<: *common-restart-policy 64 | environment: 65 | PGADMIN_DEFAULT_EMAIL: ${DB_ADMIN_EMAIL} 66 | PGADMIN_DEFAULT_PASSWORD: ${DB_ADMIN_PASSWORD} 67 | ports: 68 | - "127.0.0.1:${DB_ADMIN_PORT}:80" 69 | networks: 70 | - liteend-net 71 | logging: *common-logging 72 | depends_on: 73 | - db 74 | 75 | redis: 76 | image: redis:7.4-alpine 77 | <<: *common-restart-policy 78 | command: > 79 | redis-server --save 20 1 --loglevel warning 80 | --requirepass ${REDIS_PASSWORD} --port ${REDIS_PORT} 81 | volumes: 82 | - ./data/redis:/data 83 | ports: 84 | - "127.0.0.1:${REDIS_PORT}:${REDIS_PORT}" 85 | networks: 86 | - liteend-net 87 | logging: *common-logging 88 | healthcheck: 89 | test: ["CMD", "redis-cli", "-h", "localhost", "-p", "${REDIS_PORT}", "--raw", "ping"] 90 | interval: 5s 91 | timeout: 5s 92 | retries: 5 93 | 94 | redis_admin: 95 | image: rediscommander/redis-commander:latest 96 | <<: *common-restart-policy 97 | environment: 98 | - REDIS_HOSTS=local:redis:${REDIS_PORT} 99 | - REDIS_PASSWORD=${REDIS_PASSWORD} 100 | - HTTP_USER=${REDIS_ADMIN_USER} 101 | - HTTP_PASSWORD=${REDIS_ADMIN_PASSWORD} 102 | ports: 103 | - "127.0.0.1:${REDIS_ADMIN_PORT}:8081" 104 | networks: 105 | - liteend-net 106 | logging: *common-logging 107 | depends_on: 108 | - redis 109 | 110 | db_backup: 111 | build: 112 | context: . 113 | dockerfile: Dockerfile.database-backup 114 | <<: *common-restart-policy 115 | environment: 116 | DATABASE_HOST: db 117 | DATABASE_PORT: ${DATABASE_PORT} 118 | DATABASE_USER: ${DATABASE_USER} 119 | DATABASE_PASSWORD: ${DATABASE_PASSWORD} 120 | DATABASE_NAME: ${DATABASE_NAME} 121 | BACKUP_DIR: /app/data/database_backups 122 | BACKUP_INTERVAL: 14400000 123 | BACKUP_ROTATION: 20 124 | BACKUP_FORMAT: plain 125 | BACKUP_COMPRESS: "true" 126 | volumes: 127 | - ./data/database_backups:/app/data/database_backups 128 | networks: 129 | - liteend-net 130 | logging: *common-logging 131 | depends_on: 132 | db: 133 | condition: service_healthy 134 | 135 | networks: 136 | liteend-net: 137 | driver: bridge 138 | -------------------------------------------------------------------------------- /src/app/debug/debug.resolver.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'node:fs'; 2 | import path from 'node:path'; 3 | import { Logger, UseGuards } from '@nestjs/common'; 4 | import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; 5 | import GraphQLJSON from 'graphql-type-json'; 6 | import { I18n, I18nContext } from 'nestjs-i18n'; 7 | import { I18nTranslations } from '@/@generated/i18n-types'; 8 | import { ProfileRole } from '@/@generated/prisma/enums'; 9 | import { 10 | CurrentUser, 11 | CurrentUserType, 12 | } from '@/common/auth/current-user.decorator'; 13 | import { JwtOptionalAuthGuard } from '@/common/auth/jwt-optional-auth.guard'; 14 | import { Roles } from '@/common/auth/roles.decorator'; 15 | import { PrismaService } from '@/common/prisma/prisma.service'; 16 | import packageJson from '../../../package.json'; 17 | 18 | interface CommitInfo { 19 | name: string; 20 | hash: string; 21 | } 22 | 23 | const LAST_COMMIT_INFO_FILE_PATH = path.resolve( 24 | process.cwd(), 25 | 'dist', 26 | 'last-commit-info.json', 27 | ); 28 | 29 | @Resolver(() => Query) 30 | export class DebugResolver { 31 | private static readonly logger = new Logger(DebugResolver.name); 32 | 33 | constructor(private readonly prisma: PrismaService) {} 34 | 35 | private static readLastCommitInfo(): CommitInfo | undefined { 36 | try { 37 | return JSON.parse(readFileSync(LAST_COMMIT_INFO_FILE_PATH, 'utf8')); 38 | } catch (error) { 39 | DebugResolver.logger.error( 40 | 'Error reading last commit info. Returning empty commit info.', 41 | error, 42 | ); 43 | return undefined; 44 | } 45 | } 46 | 47 | @Query(() => String, { name: 'testTranslation' }) 48 | testTranslation( 49 | @Args('username', { type: () => String }) username: string, 50 | @I18n() i18n: I18nContext, 51 | ): string { 52 | return i18n.t('translations.hello', { 53 | args: { 54 | username, 55 | }, 56 | }); 57 | } 58 | 59 | @Query(() => String, { name: 'echo' }) 60 | echo(@Args('text', { type: () => String }) text: string): string { 61 | DebugResolver.logger.log({ resolver: 'echo', text }); 62 | return text; 63 | } 64 | 65 | @Mutation(() => String, { name: 'echo' }) 66 | echoMutation(@Args('text', { type: () => String }) text: string): string { 67 | return text; 68 | } 69 | 70 | @UseGuards(JwtOptionalAuthGuard) 71 | @Roles(ProfileRole.ADMIN, ProfileRole.USER) 72 | @Query(() => GraphQLJSON, { name: 'debug' }) 73 | async debug(@CurrentUser() user: CurrentUserType): Promise { 74 | const SECONDS_IN_DAY = 86_400; 75 | const SECONDS_IN_HOUR = 3600; 76 | const SECONDS_IN_MINUTE = 60; 77 | const uptime = process.uptime(); 78 | const uptimeDays = Math.floor(uptime / SECONDS_IN_DAY); 79 | const uptimeHours = Math.floor((uptime % SECONDS_IN_DAY) / SECONDS_IN_HOUR); 80 | const uptimeMinutes = Math.floor( 81 | ((uptime % SECONDS_IN_DAY) % SECONDS_IN_HOUR) / SECONDS_IN_MINUTE, 82 | ); 83 | const uptimeSeconds = Math.floor( 84 | ((uptime % SECONDS_IN_DAY) % SECONDS_IN_HOUR) % SECONDS_IN_MINUTE, 85 | ); 86 | const uptimePretty = `${uptimeDays}d ${uptimeHours}h ${uptimeMinutes}m ${uptimeSeconds}s`; 87 | 88 | // Logic for sensitive data 89 | let totalUsers: number | undefined; 90 | 91 | // Check if user exists AND has ADMIN role 92 | if (user?.roles.includes(ProfileRole.ADMIN)) { 93 | totalUsers = await this.prisma.profile.count(); 94 | } 95 | 96 | const memoryUsage = process.memoryUsage(); 97 | 98 | return { 99 | serverTime: new Date().toISOString(), 100 | uptime: uptimePretty, 101 | appInfo: { 102 | name: packageJson.name, 103 | version: packageJson.version, 104 | description: packageJson.description, 105 | }, 106 | memory: { 107 | rss: `${Math.round(memoryUsage.rss / 1024 / 1024)} MB`, 108 | heapTotal: `${Math.round(memoryUsage.heapTotal / 1024 / 1024)} MB`, 109 | heapUsed: `${Math.round(memoryUsage.heapUsed / 1024 / 1024)} MB`, 110 | }, 111 | lastCommit: 112 | DebugResolver.readLastCommitInfo() || 'No commit info available', 113 | totalUsers, 114 | requester: user 115 | ? `User ID: ${user.id} (${user.roles.join(', ')})` 116 | : 'Anonymous', 117 | }; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/common/logger-serve/logger-serve.controller.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | import { 5 | Controller, 6 | Get, 7 | HttpStatus, 8 | Logger, 9 | Param, 10 | Query, 11 | Res, 12 | UseGuards, 13 | } from '@nestjs/common'; 14 | import { FastifyReply } from 'fastify'; 15 | 16 | import { AuthGuard } from '@/common/logger-serve/auth/auth.guard'; 17 | import { LOGGER_UI_HTML } from './logger-ui.html'; 18 | 19 | @Controller('logs') 20 | @UseGuards(AuthGuard) 21 | export class LoggerServeController { 22 | private readonly logger = new Logger(LoggerServeController.name); 23 | private readonly logsDirectory = path.join(process.cwd(), 'data', 'logs'); 24 | 25 | @Get() 26 | async getDashboard(@Res() response: FastifyReply): Promise { 27 | response.header('Content-Type', 'text/html'); 28 | response.header('Cache-Control', 'no-cache, no-store, must-revalidate'); 29 | response.header('Pragma', 'no-cache'); 30 | response.header('Expires', '0'); 31 | 32 | response.send(LOGGER_UI_HTML); 33 | } 34 | 35 | @Get('api/list') 36 | async getFilesList(@Res() response: FastifyReply): Promise { 37 | response.header('Cache-Control', 'no-cache, no-store, must-revalidate'); 38 | 39 | try { 40 | const files = await this.scanDir(this.logsDirectory); 41 | 42 | if (files.length === 0) { 43 | this.logger.warn( 44 | `No log files found in directory: ${this.logsDirectory}`, 45 | ); 46 | } 47 | 48 | const relativeFiles = files.map((f) => 49 | path.relative(this.logsDirectory, f).replace(/\\/g, '/'), 50 | ); 51 | 52 | relativeFiles.sort(); 53 | 54 | response.send(relativeFiles); 55 | } catch (error) { 56 | this.logger.error('Error listing files:', error); 57 | response 58 | .code(HttpStatus.INTERNAL_SERVER_ERROR) 59 | .send({ error: 'Unable to list files' }); 60 | } 61 | } 62 | 63 | @Get('file/*') 64 | async getFileContent( 65 | @Param('*') _filepath: string, 66 | @Query('start') startQuery: string, 67 | @Res() response: FastifyReply, 68 | ): Promise { 69 | await this.serveFile(_filepath, startQuery, response); 70 | } 71 | 72 | private isValidFilePath(filePath: string): boolean { 73 | const absolutePath = path.resolve(this.logsDirectory, filePath); 74 | return absolutePath.startsWith(this.logsDirectory); 75 | } 76 | 77 | private async serveFile( 78 | filepath: string, 79 | startQuery: string, 80 | response: FastifyReply, 81 | ): Promise { 82 | if (!this.isValidFilePath(filepath)) { 83 | response.code(HttpStatus.FORBIDDEN).send('Access denied'); 84 | return; 85 | } 86 | 87 | const absolutePath = path.join(this.logsDirectory, filepath); 88 | 89 | try { 90 | const stats = await fs.promises.stat(absolutePath); 91 | 92 | if (stats.isDirectory()) { 93 | response.code(HttpStatus.BAD_REQUEST).send('Is a directory'); 94 | return; 95 | } 96 | 97 | let start = Number.parseInt(startQuery || '0', 10); 98 | if (Number.isNaN(start)) start = 0; 99 | 100 | const totalSize = stats.size; 101 | 102 | if (start > totalSize) start = 0; 103 | 104 | response.header('X-File-Size', totalSize.toString()); 105 | response.header('Content-Type', 'text/plain'); 106 | response.header('Cache-Control', 'no-cache'); 107 | 108 | if (start === totalSize) { 109 | response.send(''); 110 | return; 111 | } 112 | 113 | const stream = fs.createReadStream(absolutePath, { 114 | start: start, 115 | encoding: 'utf8', 116 | }); 117 | 118 | stream.on('error', (err) => { 119 | this.logger.error(`Stream error: ${err.message}`); 120 | if (!response.sent) response.send(); 121 | }); 122 | 123 | response.send(stream); 124 | } catch { 125 | response.code(HttpStatus.NOT_FOUND).send('File not found'); 126 | } 127 | } 128 | 129 | private async scanDir(dir: string): Promise { 130 | const results: string[] = []; 131 | try { 132 | await fs.promises.access(dir); 133 | const list = await fs.promises.readdir(dir, { withFileTypes: true }); 134 | for (const item of list) { 135 | const fullPath = path.join(dir, item.name); 136 | if (item.isDirectory()) { 137 | const subFiles = await this.scanDir(fullPath); 138 | results.push(...subFiles); 139 | } else if (item.isFile() && !item.name.startsWith('.')) { 140 | results.push(fullPath); 141 | } 142 | } 143 | } catch { 144 | return []; 145 | } 146 | return results; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/db-backup-tool/logger.ts: -------------------------------------------------------------------------------- 1 | import { inspect } from 'node:util'; 2 | 3 | enum LogLevel { 4 | DEBUG = 0, 5 | INFO = 1, 6 | WARN = 2, 7 | ERROR = 3, 8 | CRITICAL = 4, 9 | } 10 | 11 | enum LogFormat { 12 | PLAIN = 'plain', 13 | JSON = 'json', 14 | } 15 | 16 | interface LoggerConfig { 17 | name: string; 18 | level?: LogLevel; 19 | logTime?: boolean; 20 | useColors?: boolean; 21 | format?: LogFormat; 22 | } 23 | 24 | export class Logger { 25 | private readonly level: LogLevel; 26 | private readonly name: string; 27 | private readonly logTime: boolean; 28 | private readonly useColors: boolean; 29 | private readonly format: LogFormat; 30 | 31 | private readonly colors = { 32 | debug: '\u001B[36m', // cyan 33 | info: '\u001B[32m', // green 34 | warn: '\u001B[33m', // yellow 35 | error: '\u001B[31m', // red 36 | critical: '\u001B[35m', // magenta 37 | reset: '\u001B[0m', // reset 38 | }; 39 | 40 | constructor(config: LoggerConfig) { 41 | this.name = config.name; 42 | this.level = config.level ?? LogLevel.INFO; 43 | this.logTime = config.logTime ?? true; 44 | this.useColors = config.useColors ?? true; 45 | this.format = config.format ?? LogFormat.PLAIN; 46 | } 47 | 48 | private shouldLog(level: LogLevel): boolean { 49 | return level >= this.level; 50 | } 51 | 52 | private getLevelKey(level: LogLevel): keyof typeof this.colors { 53 | switch (level) { 54 | case LogLevel.DEBUG: { 55 | return 'debug'; 56 | } 57 | case LogLevel.INFO: { 58 | return 'info'; 59 | } 60 | case LogLevel.WARN: { 61 | return 'warn'; 62 | } 63 | case LogLevel.ERROR: { 64 | return 'error'; 65 | } 66 | case LogLevel.CRITICAL: { 67 | return 'critical'; 68 | } 69 | default: { 70 | throw new Error(`Unknown log level: ${level}`); 71 | } 72 | } 73 | } 74 | 75 | private getLevelName(level: LogLevel): string { 76 | return LogLevel[level] || 'UNKNOWN'; 77 | } 78 | 79 | private formatMessage(level: LogLevel, ...messages: unknown[]): string { 80 | const timestamp = this.logTime ? new Date().toISOString() : undefined; 81 | const levelKey = this.getLevelKey(level); 82 | const levelColor = this.useColors ? this.colors[levelKey] : ''; 83 | const resetColor = this.useColors ? this.colors.reset : ''; 84 | const levelName = this.getLevelName(level); 85 | 86 | const message = messages 87 | .map((message_) => this.serialize(message_)) 88 | .join(' '); 89 | 90 | if (this.format === LogFormat.JSON) { 91 | const logObject = { 92 | timestamp, 93 | name: this.name, 94 | level: levelName, 95 | message, 96 | }; 97 | return JSON.stringify(logObject); 98 | } 99 | 100 | const parts = [ 101 | timestamp && `[${timestamp}]`, 102 | `[${this.name}]`, 103 | `[${levelName}]`, 104 | message, 105 | ]; 106 | 107 | const formattedMessage = parts.filter(Boolean).join(' '); 108 | return this.useColors 109 | ? `${levelColor}${formattedMessage}${resetColor}` 110 | : formattedMessage; 111 | } 112 | 113 | private serialize(data: unknown): string { 114 | try { 115 | if (typeof data === 'object' && data !== null) { 116 | return inspect(data, { colors: this.useColors, depth: 2 }); 117 | } 118 | return String(data); 119 | } catch (error) { 120 | return `Serialization error: ${error instanceof Error ? error.message : 'Unknown error'}`; 121 | } 122 | } 123 | 124 | private log(level: LogLevel, ...messages: unknown[]): void { 125 | if (!this.shouldLog(level)) return; 126 | 127 | const formattedMessage = this.formatMessage(level, ...messages); 128 | switch (level) { 129 | case LogLevel.ERROR: 130 | case LogLevel.CRITICAL: { 131 | console.error(formattedMessage); 132 | break; 133 | } 134 | case LogLevel.WARN: { 135 | console.warn(formattedMessage); 136 | break; 137 | } 138 | default: { 139 | console.log(formattedMessage); 140 | } 141 | } 142 | } 143 | 144 | public debug(...messages: unknown[]): void { 145 | this.log(LogLevel.DEBUG, ...messages); 146 | } 147 | 148 | public info(...messages: unknown[]): void { 149 | this.log(LogLevel.INFO, ...messages); 150 | } 151 | 152 | public warn(...messages: unknown[]): void { 153 | this.log(LogLevel.WARN, ...messages); 154 | } 155 | 156 | public error(...messages: unknown[]): void { 157 | this.log(LogLevel.ERROR, ...messages); 158 | } 159 | 160 | public critical(...messages: unknown[]): void { 161 | this.log(LogLevel.CRITICAL, ...messages); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liteend", 3 | "version": "0.0.1", 4 | "description": "Lightweight, fast, and easy to use backend app template for Node.js", 5 | "author": "uxname", 6 | "private": true, 7 | "license": "MIT", 8 | "scripts": { 9 | "________________ BUILD AND RUN ________________": "", 10 | "save-commit-info": "tsx src/common/git-commit-saver.ts", 11 | "build": "rimraf dist && npm run save-commit-info && run-p lint build:nest", 12 | "build:nest": "nest build", 13 | "start:dev": "cross-env NODE_ENV=development nest start --watch", 14 | "start:debug": "cross-env NODE_ENV=development nest start --debug --watch", 15 | "prestart:prod": "npm run db:migrations:apply", 16 | "start:prod": "cross-env NODE_ENV=production node --enable-source-maps dist/src/main", 17 | "________________ FORMAT AND LINT ________________": "", 18 | "lint": "biome check", 19 | "lint:md": "markdownlint '**/*.md' --ignore node_modules --fix --disable MD013", 20 | "lint:fix": "biome check --write", 21 | "lint:fix:unsafe": "biome check --write --unsafe", 22 | "ts:check": "tsc --noEmit", 23 | "knip": "knip --production", 24 | "check": "run-p ts:check lint knip lint:md", 25 | "________________ TEST ________________": "", 26 | "test": "vitest run", 27 | "test:watch": "vitest", 28 | "test:cov": "vitest run --coverage", 29 | "test:e2e": "vitest run --config ./vitest.config.ts --dir test", 30 | "________________ DATABASE ________________": "", 31 | "db:push": "prisma db push && npm run db:gen", 32 | "db:reset": "prisma migrate reset --force", 33 | "db:migrations:apply": "prisma migrate deploy && npm run db:gen", 34 | "db:migrations:create": "prisma migrate dev --create-only", 35 | "db:schema:format": "prisma format", 36 | "db:gen": "prisma generate --no-hints", 37 | "db:studio": "prisma studio --browser none --port 5555", 38 | "db:seed": "tsx prisma/seed.ts", 39 | "________________ OTHER ________________": "", 40 | "postinstall": "npm run prepare", 41 | "prepare": "lefthook install", 42 | "update": "npm-check-updates -u && rimraf node_modules package-lock.json && npm i", 43 | "postupdate": "npm run lint:fix && npm run check" 44 | }, 45 | "dependencies": { 46 | "@bull-board/api": "^6.15.0", 47 | "@bull-board/fastify": "^6.15.0", 48 | "@bull-board/nestjs": "^6.15.0", 49 | "@dotenvx/dotenvx": "^1.51.1", 50 | "@fastify/compress": "^8.3.0", 51 | "@fastify/helmet": "^13.0.2", 52 | "@fastify/multipart": "^9.3.0", 53 | "@fastify/rate-limit": "^10.3.0", 54 | "@fastify/static": "^8.3.0", 55 | "@nestjs/bullmq": "^11.0.4", 56 | "@nestjs/common": "^11.1.9", 57 | "@nestjs/config": "^4.0.2", 58 | "@nestjs/core": "^11.1.9", 59 | "@nestjs/graphql": "^13.2.0", 60 | "@nestjs/mercurius": "^13.2.0", 61 | "@nestjs/passport": "^11.0.5", 62 | "@nestjs/platform-fastify": "^11.1.9", 63 | "@nestjs/swagger": "^11.2.3", 64 | "@nestjs/websockets": "^11.1.9", 65 | "@prisma/adapter-pg": "^7.1.0", 66 | "@prisma/client": "^7.1.0", 67 | "altair-fastify-plugin": "^8.4.1", 68 | "bullmq": "^5.65.1", 69 | "class-transformer": "^0.5.1", 70 | "class-validator": "^0.14.3", 71 | "cross-env": "^10.1.0", 72 | "fastify": "^5.6.2", 73 | "graphql": "^16.12.0", 74 | "graphql-query-complexity": "^1.1.0", 75 | "graphql-type-json": "^0.3.2", 76 | "graphql-ws": "^6.0.6", 77 | "ioredis": "^5.8.2", 78 | "jwks-rsa": "^3.2.0", 79 | "mercurius": "^16.6.0", 80 | "mqemitter-redis": "^7.2.0", 81 | "mrmime": "^2.0.1", 82 | "nestjs-i18n": "^10.6.0", 83 | "nestjs-pino": "^4.5.0", 84 | "nestjs-zod": "^5.0.1", 85 | "ofetch": "^1.5.1", 86 | "passport": "^0.7.0", 87 | "passport-jwt": "^4.0.1", 88 | "pg": "^8.16.3", 89 | "pino": "^10.1.0", 90 | "pino-http": "^11.0.0", 91 | "pino-pretty": "^13.1.3", 92 | "pino-roll": "^4.0.0", 93 | "reflect-metadata": "^0.2.2", 94 | "rxjs": "^7.8.2", 95 | "zod": "^4.1.13" 96 | }, 97 | "devDependencies": { 98 | "@biomejs/biome": "2.3.8", 99 | "@nestjs/cli": "^11.0.14", 100 | "@nestjs/schematics": "^11.0.9", 101 | "@nestjs/testing": "^11.1.9", 102 | "@swc/core": "^1.15.3", 103 | "@types/ioredis": "^5.0.0", 104 | "@types/node": "^25.0.0", 105 | "@types/passport-jwt": "^4.0.1", 106 | "@types/pg": "^8.16.0", 107 | "knip": "^5.73.1", 108 | "lefthook": "^2.0.9", 109 | "markdownlint-cli": "^0.46.0", 110 | "npm-check-updates": "^19.2.0", 111 | "npm-run-all": "^4.1.5", 112 | "pactum": "^3.8.0", 113 | "prisma": "^7.1.0", 114 | "rimraf": "^6.1.2", 115 | "tsconfig-paths": "4.2.0", 116 | "tsx": "^4.21.0", 117 | "typescript": "^5.9.3", 118 | "unplugin-swc": "^1.5.9", 119 | "vite-tsconfig-paths": "^5.1.4", 120 | "vitest": "^4.0.15" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import * as process from 'node:process'; 3 | import { BullModule } from '@nestjs/bullmq'; 4 | import { Module } from '@nestjs/common'; 5 | import { ConfigModule, ConfigService } from '@nestjs/config'; 6 | import { APP_FILTER } from '@nestjs/core'; 7 | import { GraphQLModule } from '@nestjs/graphql'; 8 | import { MercuriusDriver, MercuriusDriverConfig } from '@nestjs/mercurius'; 9 | import { FastifyReply, FastifyRequest } from 'fastify'; 10 | import { GraphQLError } from 'graphql/error'; 11 | import { getComplexity, simpleEstimator } from 'graphql-query-complexity'; 12 | import GraphQLJSON from 'graphql-type-json'; 13 | import { AcceptLanguageResolver, I18nModule } from 'nestjs-i18n'; 14 | import { AppController } from '@/app/app.controller'; 15 | import { DebugModule } from '@/app/debug/debug.module'; 16 | import { FileUploadModule } from '@/app/file-upload/file-upload.module'; 17 | import { HealthModule } from '@/app/health/health.module'; 18 | import { ProfileModule } from '@/app/profile/profile.module'; 19 | import { TestQueueModule } from '@/app/test-queue/test-queue.module'; 20 | import { AllExceptionsFilter } from '@/common/all-exceptions-filter'; 21 | import { AuthModule } from '@/common/auth/auth.module'; 22 | import { BullBoardModule } from '@/common/bull-board/bull-board.module'; 23 | import { DotenvValidatorModule } from '@/common/dotenv-validator/dotenv-validator.module'; 24 | import { gqlErrorFormatter } from '@/common/graphql/error-formatter'; 25 | import { LoggerModule } from '@/common/logger/logger.module'; 26 | import { LoggerServeModule } from '@/common/logger-serve/logger-serve.module'; 27 | import { PrismaModule } from '@/common/prisma/prisma.module'; 28 | import { PrismaStudioModule } from '@/common/prisma-studio/prisma-studio.module'; 29 | 30 | // eslint-disable-next-line @typescript-eslint/no-require-imports 31 | const mqEmitterRedis = require('mqemitter-redis'); 32 | 33 | @Module({ 34 | imports: [ 35 | AuthModule, 36 | TestQueueModule, 37 | GraphQLModule.forRootAsync({ 38 | driver: MercuriusDriver, 39 | imports: [ConfigModule], 40 | inject: [ConfigService], 41 | useFactory: async (configService: ConfigService) => ({ 42 | autoSchemaFile: true, 43 | graphiql: false, 44 | jit: 1, 45 | cache: true, 46 | 47 | validationRules: [ 48 | (context) => ({ 49 | OperationDefinition(node) { 50 | const schema = context.getSchema(); 51 | const complexity = getComplexity({ 52 | schema, 53 | operationName: node.name?.value, 54 | query: context.getDocument(), 55 | variables: undefined, 56 | estimators: [simpleEstimator({ defaultComplexity: 1 })], 57 | }); 58 | 59 | const MAX_COMPLEXITY = 200; 60 | 61 | if (complexity > MAX_COMPLEXITY) { 62 | context.reportError( 63 | new GraphQLError( 64 | `Query is too complex: ${complexity}. Maximum allowed complexity: ${MAX_COMPLEXITY}`, 65 | ), 66 | ); 67 | } 68 | }, 69 | }), 70 | ], 71 | 72 | subscription: { 73 | emitter: mqEmitterRedis({ 74 | host: configService.getOrThrow('REDIS_HOST'), 75 | port: configService.getOrThrow('REDIS_PORT'), 76 | password: configService.getOrThrow('REDIS_PASSWORD'), 77 | }), 78 | onConnect: (data) => { 79 | const payload = data.payload || data || {}; 80 | const headers = payload.headers || payload || {}; 81 | 82 | return { 83 | req: { 84 | headers: { 85 | authorization: headers.Authorization || headers.authorization, 86 | }, 87 | }, 88 | }; 89 | }, 90 | }, 91 | 92 | context: (request: FastifyRequest, reply: FastifyReply) => { 93 | return { req: request, res: reply }; 94 | }, 95 | 96 | resolvers: { JSON: GraphQLJSON }, 97 | errorFormatter: gqlErrorFormatter, 98 | }), 99 | }), 100 | BullBoardModule, 101 | DebugModule, 102 | LoggerModule, 103 | PrismaModule, 104 | PrismaStudioModule, 105 | LoggerServeModule, 106 | FileUploadModule, 107 | HealthModule, 108 | DotenvValidatorModule, 109 | BullModule.forRootAsync({ 110 | imports: [ConfigModule], 111 | useFactory: async (configService: ConfigService) => ({ 112 | connection: { 113 | host: configService.getOrThrow('REDIS_HOST'), 114 | port: Number.parseInt( 115 | configService.getOrThrow('REDIS_PORT'), 116 | 10, 117 | ), 118 | password: configService.getOrThrow('REDIS_PASSWORD'), 119 | }, 120 | }), 121 | inject: [ConfigService], 122 | }), 123 | ConfigModule.forRoot({ 124 | envFilePath: ['.env', '.env.example'], 125 | ignoreEnvFile: process.env.NODE_ENV === 'production', 126 | isGlobal: true, 127 | }), 128 | I18nModule.forRoot({ 129 | fallbackLanguage: 'en', 130 | loaderOptions: { 131 | path: path.join(process.cwd(), 'src', 'i18n'), 132 | watch: true, 133 | }, 134 | resolvers: [AcceptLanguageResolver], 135 | logging: true, 136 | typesOutputPath: path.join( 137 | process.cwd(), 138 | 'src', 139 | '@generated', 140 | 'i18n-types.ts', 141 | ), 142 | }), 143 | ProfileModule, 144 | ], 145 | controllers: [AppController], 146 | providers: [ 147 | { 148 | provide: APP_FILTER, 149 | useClass: AllExceptionsFilter, 150 | }, 151 | ], 152 | }) 153 | export class AppModule {} 154 | -------------------------------------------------------------------------------- /src/db-backup-tool/backup.ts: -------------------------------------------------------------------------------- 1 | import * as childProcess from 'node:child_process'; 2 | import * as fs from 'node:fs/promises'; 3 | import path from 'node:path'; 4 | 5 | import { Logger } from './logger'; 6 | 7 | const logger = new Logger({ name: 'Backup' }); 8 | 9 | // Environment variables with strict types and validation 10 | interface EnvironmentVariables { 11 | DATABASE_HOST: string; 12 | DATABASE_PORT: string; 13 | DATABASE_USER: string; 14 | DATABASE_PASSWORD: string; 15 | DATABASE_NAME: string; 16 | BACKUP_DIR: string; 17 | BACKUP_INTERVAL: number; 18 | BACKUP_ROTATION: number; 19 | BACKUP_FORMAT: 'custom' | 'plain'; 20 | BACKUP_COMPRESS: boolean; 21 | } 22 | 23 | const environment: EnvironmentVariables = { 24 | DATABASE_HOST: process.env.DATABASE_HOST || 'localhost', 25 | DATABASE_PORT: process.env.DATABASE_PORT || '5432', 26 | DATABASE_USER: process.env.DATABASE_USER || 'postgres', 27 | DATABASE_PASSWORD: process.env.DATABASE_PASSWORD || 'postgres', 28 | DATABASE_NAME: process.env.DATABASE_NAME || 'postgres', 29 | BACKUP_DIR: process.env.BACKUP_DIR || './data/database_backups', 30 | BACKUP_INTERVAL: Number.parseInt( 31 | process.env.BACKUP_INTERVAL || '86400000', 32 | 10, 33 | ), // 1 day in milliseconds 34 | BACKUP_ROTATION: Number.parseInt(process.env.BACKUP_ROTATION || '5', 10), 35 | BACKUP_FORMAT: (process.env.BACKUP_FORMAT === 'custom' 36 | ? 'custom' 37 | : 'plain') as 'custom' | 'plain', 38 | BACKUP_COMPRESS: process.env.BACKUP_COMPRESS === 'true', 39 | }; 40 | 41 | // Safe logging of environment variables 42 | const safeEnvironment: EnvironmentVariables = { 43 | ...environment, 44 | DATABASE_PASSWORD: '***', 45 | }; 46 | 47 | logger.info('Environment variables:', safeEnvironment); 48 | 49 | // Ensure backup directory exists 50 | async function ensureBackupDirectoryExists(): Promise { 51 | try { 52 | await fs.access(environment.BACKUP_DIR); 53 | } catch { 54 | await fs.mkdir(environment.BACKUP_DIR, { recursive: true }); 55 | logger.info(`Created backup directory: ${environment.BACKUP_DIR}`); 56 | } 57 | } 58 | 59 | let isBackingUp = false; 60 | 61 | // Function to create a backup 62 | async function createBackup(): Promise { 63 | if (isBackingUp) { 64 | logger.warn('Another backup is already in progress. Skipping this backup.'); 65 | return; 66 | } 67 | isBackingUp = true; 68 | 69 | try { 70 | const timestamp: string = new Date().toISOString().replaceAll(/[:.]/g, '-'); 71 | const backupFileName: string = `${environment.DATABASE_NAME}_${timestamp}.${ 72 | environment.BACKUP_COMPRESS ? 'sql.gz' : 'sql' 73 | }`; 74 | const backupFilePath: string = path.join( 75 | environment.BACKUP_DIR, 76 | backupFileName, 77 | ); 78 | 79 | const pgDumpOptions: string[] = [ 80 | `-h ${environment.DATABASE_HOST}`, 81 | `-p ${environment.DATABASE_PORT}`, 82 | `-U ${environment.DATABASE_USER}`, 83 | `-d ${environment.DATABASE_NAME}`, 84 | environment.BACKUP_FORMAT === 'custom' ? '-F c' : '-F p', // Custom or plain format 85 | '-b', // Include large objects 86 | '-v', // Verbose mode 87 | ]; 88 | 89 | const dumpCommand: string = environment.BACKUP_COMPRESS 90 | ? `pg_dump ${pgDumpOptions.join(' ')} | gzip > ${backupFilePath}` 91 | : `pg_dump ${pgDumpOptions.join(' ')} > ${backupFilePath}`; 92 | 93 | logger.info(`Starting backup: ${backupFilePath}`); 94 | await executeCommand(dumpCommand, backupFilePath); 95 | await rotateBackups(); 96 | } catch (error) { 97 | logger.error('Error during backup:', error); 98 | } finally { 99 | isBackingUp = false; 100 | } 101 | } 102 | 103 | // Execute a shell command and handle errors 104 | async function executeCommand( 105 | command: string, 106 | backupFilePath: string, 107 | ): Promise { 108 | return new Promise((resolve, reject) => { 109 | childProcess.exec( 110 | command, 111 | { env: { ...process.env, PGPASSWORD: environment.DATABASE_PASSWORD } }, 112 | (error) => { 113 | if (error) { 114 | logger.error('Backup failed:', error); 115 | fs.unlink(backupFilePath).catch((unlinkError) => { 116 | logger.warn( 117 | `Failed to delete empty backup file: ${backupFilePath}`, 118 | unlinkError, 119 | ); 120 | }); 121 | reject(error); 122 | } else { 123 | logger.info('Backup completed successfully.'); 124 | resolve(); 125 | } 126 | }, 127 | ); 128 | }); 129 | } 130 | 131 | // Function to rotate backups 132 | async function rotateBackups(): Promise { 133 | try { 134 | const files: string[] = (await fs.readdir(environment.BACKUP_DIR)).filter( 135 | (file) => file.endsWith(environment.BACKUP_COMPRESS ? '.sql.gz' : '.sql'), 136 | ); 137 | 138 | const filesWithStats = await Promise.all( 139 | files.map(async (file) => { 140 | const filePath = path.join(environment.BACKUP_DIR, file); 141 | const stats = await fs.stat(filePath); 142 | return { file, mtime: stats.mtime.getTime() }; 143 | }), 144 | ); 145 | 146 | filesWithStats.sort((a, b) => a.mtime - b.mtime); 147 | 148 | if (filesWithStats.length > environment.BACKUP_ROTATION) { 149 | const filesToDelete = filesWithStats.slice( 150 | 0, 151 | filesWithStats.length - environment.BACKUP_ROTATION, 152 | ); 153 | for (const { file } of filesToDelete) { 154 | const filePath = path.join(environment.BACKUP_DIR, file); 155 | await fs.unlink(filePath); 156 | logger.info(`Deleted old backup: ${filePath}`); 157 | } 158 | } 159 | } catch (error) { 160 | logger.error('Error during backup rotation:', error); 161 | } 162 | } 163 | 164 | // Initial backup on script start 165 | async function initializeBackup(): Promise { 166 | try { 167 | await ensureBackupDirectoryExists(); 168 | await createBackup(); 169 | } catch (error) { 170 | logger.error('Error during initial backup:', error); 171 | throw error; 172 | } 173 | } 174 | 175 | // Schedule backups at the specified interval 176 | function scheduleBackups(): void { 177 | setInterval(createBackup, environment.BACKUP_INTERVAL); 178 | } 179 | 180 | // Main function 181 | async function main(): Promise { 182 | await initializeBackup(); 183 | scheduleBackups(); 184 | } 185 | 186 | main().catch((error) => { 187 | logger.error('Fatal error in backup script:', error); 188 | throw error; 189 | }); 190 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # [![LiteEnd logo](assets/logo.png)](https://github.com/uxname/liteend) 2 | 3 | [![Checked with Biome](https://img.shields.io/badge/Checked_with-Biome-60a5fa?style=flat&logo=biome)](https://biomejs.dev) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-brightgreen.svg)](https://opensource.org/licenses/MIT) 5 | [![GitHub stars](https://img.shields.io/github/stars/uxname/liteend)](https://github.com/uxname/liteend/stargazers) 6 | 7 | Lightweight, fast, and easy-to-use backend app template for Node.js, based on [NestJS](https://nestjs.com/). 8 | Uses [Prisma.io](https://www.prisma.io) and PostgreSQL as a base for data storage, Redis for caching/queues, and OIDC for authentication. 9 | 10 | ## ⚡️ TL;DR — Quick Start 11 | 12 | Start a new project based on LiteEnd in seconds. This command downloads the template (without git history), sets up the environment file, and installs dependencies. 13 | 14 | ### One-line initialization 15 | 16 | Replace `my-app` with your project name: 17 | 18 | ```bash 19 | npx degit uxname/liteend my-app && cd my-app && git init && cp .env.example .env && npm install 20 | ``` 21 | 22 | ### What's next? 23 | 24 | 1. **Database:** Update `.env` with your credentials and run `docker-compose up -d`. 25 | 2. **Migrations:** Run `npm run db:migrations:apply`. 26 | 3. **Run:** `npm run start:dev`. 27 | 28 | ## Table of Contents 29 | 30 | - [Table of Contents](#table-of-contents) 31 | - [Features](#features) 32 | - [Tech Stack](#tech-stack) 33 | - [Prerequisites](#prerequisites) 34 | - [Installation](#installation) 35 | - [Usage](#usage) 36 | - [Development](#development) 37 | - [Production](#production) 38 | - [API Documentation (Swagger)](#api-documentation-swagger) 39 | - [System Endpoints](#system-endpoints) 40 | - [Health Check](#health-check) 41 | - [Logs](#logs) 42 | - [Database Admin Panel](#database-admin-panel-prisma-studio) 43 | - [Docker](#docker) 44 | - [Overview](#overview) 45 | - [Docker Compose Usage](#docker-compose-usage) 46 | - [Accessing Services](#accessing-services) 47 | - [Database Backup/Restore](#database-backuprestore) 48 | - [Viewing Logs](#viewing-logs) 49 | - [Database Workflow (Prisma)](#database-workflow-prisma) 50 | - [Code Quality](#code-quality) 51 | - [Linting & Formatting](#linting--formatting) 52 | - [Testing](#testing) 53 | - [Configuration](#configuration) 54 | - [Key Environment Variables](#key-environment-variables) 55 | - [Internationalization (i18n)](#internationalization-i18n) 56 | - [Contributing](#contributing) 57 | - [Show Your Support](#show-your-support) 58 | - [License](#license) 59 | 60 | ## Features 61 | 62 | - **NestJS Framework:** Robust and scalable backend structure. 63 | - **Prisma ORM:** Type-safe database access with PostgreSQL. 64 | - **Redis Integration:** Support for caching and background jobs using Bull queues. 65 | - **Docker Support:** Easy setup and deployment with Docker and Docker Compose (App, PostgreSQL, Redis, Admin UIs, Backup). 66 | - **Code Quality Tools:** Integrated Biome (linting/formatting) and Vitest (testing). 67 | - **Authentication:** Secure OIDC (OpenID Connect) integration (Logto, Auth0, etc.) with lazy user registration. 68 | - **Database Migrations:** Managed schema changes with Prisma Migrate. 69 | - **Configuration Management:** Environment-based configuration using `.env` files. 70 | - **Logging:** High-performance structured logging with Pino (file rotation included). 71 | - **API Documentation:** Basic Swagger UI setup. 72 | - **WebSockets:** Support for real-time communication. 73 | - **Task Queues:** Bull module integration. 74 | - **Email:** Mailer module integration. 75 | - **GraphQL:** Apollo server integration. 76 | - **Health Checks:** Endpoint for monitoring application status. 77 | - **Automated DB Backups:** Scheduled database backups via a dedicated Docker service. 78 | - **Internationalization (i18n):** Support for multiple languages. 79 | 80 | ## Tech Stack 81 | 82 | - **Language:** TypeScript 83 | - **Framework:** NestJS 84 | - **ORM:** Prisma 85 | - **Database:** PostgreSQL 86 | - **Caching/Queues:** Redis 87 | - **Containerization:** Docker, Docker Compose 88 | - **Package Manager:** npm 89 | - **Linting/Formatting:** BiomeJS 90 | - **Testing:** Vitest, Pactum 91 | - **Logging:** Pino 92 | - **Admin Tools (via Docker):** pgAdmin 4, Redis Commander 93 | 94 | ## Prerequisites 95 | 96 | - **Node.js:** Use a recent LTS version (e.g., 18.x, 20.x). Check project specifics if needed. 97 | - **npm:** Usually comes with Node.js. 98 | - **Git:** For cloning the repository. 99 | - **Docker & Docker Compose:** (Recommended) For easy environment setup and running PostgreSQL/Redis. 100 | 101 | ## Installation 102 | 103 | 1. **Clone the repository:** 104 | 105 | ```bash 106 | git clone https://github.com/uxname/liteend.git 107 | cd liteend 108 | ``` 109 | 110 | 2. **(Optional) Change Git remote URL:** 111 | 112 | ```bash 113 | git remote set-url origin 114 | ``` 115 | 116 | 3. **Set up environment variables:** 117 | Copy the example environment file and customize it. 118 | 119 | ```bash 120 | cp .env.example .env 121 | # Edit .env file with your specific configuration (database credentials, ports, Redis details, etc.) 122 | # See the 'Configuration' section below for key variables. 123 | ``` 124 | 125 | 4. **Install dependencies:** 126 | 127 | ```bash 128 | npm install 129 | ``` 130 | 131 | 5. **(If using Docker - Recommended)** **Start external services (Database & Redis):** 132 | Ensure Docker Desktop or Docker Engine/Compose is running. 133 | 134 | ```bash 135 | docker-compose up -d db redis 136 | # Wait a few seconds for the database and Redis to initialize. 137 | ``` 138 | 139 | *Note: If you are **not** using Docker, ensure PostgreSQL and Redis are installed, running, and accessible according to your `.env` configuration.* 140 | 141 | 6. **Apply database migrations:** 142 | This command applies existing migrations to set up the database schema. 143 | 144 | ```bash 145 | npm run db:migrations:apply 146 | ``` 147 | 148 | 7. **(Optional) Seed the database:** 149 | If seed data is available, populate the database: 150 | 151 | ```bash 152 | npm run db:seed 153 | ``` 154 | 155 | ## Usage 156 | 157 | ### Development 158 | 159 | - **Run in watch mode:** 160 | The application will restart automatically on file changes. Requires database and Redis to be running. 161 | 162 | ```bash 163 | npm run start:dev 164 | ``` 165 | 166 | - **Run in debug mode:** 167 | Starts the application with the Node.js inspector enabled. 168 | 169 | ```bash 170 | npm run start:debug 171 | ``` 172 | 173 | ### Production 174 | 175 | 1. **Build the application:** 176 | This compiles TypeScript to JavaScript and performs checks. 177 | 178 | ```bash 179 | npm run build 180 | ``` 181 | 182 | 2. **Run the production build:** 183 | Starts the application from the compiled code in the `dist` folder. Ensure `NODE_ENV` is set to `production`. Requires database and Redis to be running. 184 | 185 | ```bash 186 | npm run start:prod 187 | ``` 188 | 189 | *Note: The `prestart:prod` script automatically runs `npm run db:migrations:apply` before starting.* 190 | 191 | ### API Documentation (Swagger) 192 | 193 | Once the application is running (e.g., via `npm run start:dev`), the Swagger UI for API documentation is available at: 194 | 195 | `http://localhost:/swagger` 196 | 197 | Replace `` with the application port specified in your `.env` file (default is 4000). 198 | 199 | ### System Endpoints 200 | 201 | The application provides built-in endpoints for monitoring and administration: 202 | 203 | #### Health Check 204 | 205 | - `/health`: Returns the application status. Used by Docker healthcheck. Example response: `{"status":"ok",...}` 206 | 207 | #### Logs 208 | 209 | - `/logs/`: View recent logs (requires credentials from `.env`). 210 | - `/logs/all`: View all logs. 211 | - `/logs/error`: View error logs. 212 | - *(See `src/common/logger-serve/logger-serve.controller.ts` for more)* 213 | 214 | #### Database Admin Panel (Prisma Studio) 215 | 216 | - `/studio`: Access Prisma Studio for database browsing and manipulation (runs alongside the NestJS app). Requires credentials from `.env`. 217 | 218 | #### Task Queue Dashboard (Bull Board) 219 | 220 | - `/board`: Access the Bull Board UI to monitor background job queues (e.g., email sending). Requires credentials from `.env`. 221 | 222 | #### GraphQL Debug Query 223 | 224 | - **Query:** `debug`: A GraphQL query available in the main GraphQL endpoint (`/graphql`) that returns system information like application version, uptime, and the last git commit. 225 | 226 | ## Docker 227 | 228 | ### Overview 229 | 230 | The `docker-compose.yml` file defines the following services for a complete development/testing environment: 231 | 232 | - `app`: The main NestJS application container. 233 | - `db`: PostgreSQL database container. 234 | - `redis`: Redis container (for Bull queues, caching). 235 | - `db_admin`: pgAdmin 4 container, a web UI for managing PostgreSQL. 236 | - `redis_admin`: Redis Commander container, a web UI for managing Redis. 237 | - `db_backup`: A dedicated container that performs scheduled backups of the PostgreSQL database. 238 | 239 | ### Docker Compose Usage 240 | 241 | 1. **Start all services:** 242 | Launches the app, database, Redis, and admin UIs in detached mode. 243 | 244 | ```bash 245 | docker-compose up -d 246 | ``` 247 | 248 | 2. **Start only specific services (e.g., DB and Redis):** 249 | Useful during initial setup or if running the app locally. 250 | 251 | ```bash 252 | docker-compose up -d db redis 253 | ``` 254 | 255 | 3. **Stop all services:** 256 | 257 | ```bash 258 | docker-compose down 259 | ``` 260 | 261 | 4. **Rebuild and start services:** 262 | If you've made changes to `Dockerfile` or need to rebuild images: 263 | 264 | ```bash 265 | docker-compose up -d --build 266 | ``` 267 | 268 | ### Accessing Services 269 | 270 | - **Application:** `http://localhost:` (See `PORT` in `.env`) 271 | - **pgAdmin (DB Admin):** `http://localhost:` (See `DB_ADMIN_PORT`, `DB_ADMIN_EMAIL`, `DB_ADMIN_PASSWORD` in `.env` for login) 272 | - **Redis Commander (Redis Admin):** `http://localhost:` (See `REDIS_ADMIN_PORT`, `REDIS_ADMIN_USER`, `REDIS_ADMIN_PASSWORD` in `.env` for login) 273 | - **Prisma Studio (DB Admin via App):** `http://localhost:/studio` (See `PORT`, `PRISMA_STUDIO_LOGIN`, `PRISMA_STUDIO_PASSWORD` in `.env` for login) 274 | - **Bull Board (Task Queues):** `http://localhost:/board` (See `PORT`, `BULL_BOARD_LOGIN`, `BULL_BOARD_PASSWORD` in `.env` for login) 275 | 276 | ### Database Backup/Restore 277 | 278 | - The `db_backup` service automatically creates compressed PostgreSQL backups. 279 | - **Configuration:** Schedule (`BACKUP_INTERVAL`), rotation (`BACKUP_ROTATION`), format, etc., are defined in `docker-compose.yml` for the `db_backup` service. 280 | - **Location:** Backups are stored in the volume mapped to `./data/database_backups` on the host machine. 281 | - **Manual Restore Example:** 282 | 283 | ```bash 284 | # Ensure the backup file exists in ./data/database_backups/ 285 | # Replace '...' with the actual backup filename 286 | docker compose exec db_backup sh -c "npx tsx restore.ts postgres_YYYY-MM-DDTHH-MM-SS-MSZ.sql.gz" 287 | ``` 288 | 289 | *(Refer to `db-backup-tool/restore.ts` or related scripts for details)* 290 | 291 | ### Viewing Logs 292 | 293 | You can view logs for individual services: 294 | 295 | ```bash 296 | docker-compose logs app 297 | docker-compose logs db 298 | docker-compose logs redis 299 | # Use '-f' to follow logs in real-time 300 | docker-compose logs -f app 301 | ``` 302 | 303 | ## Database Workflow (Prisma) 304 | 305 | Manage your database schema and migrations using Prisma CLI commands wrapped in npm scripts. 306 | 307 | 1. **Edit Schema:** Modify `prisma/schema.prisma`. 308 | 2. **Format Schema:** `npm run db:schema:format` 309 | 3. **Create Migration:** `npm run db:migrations:create` (Provide a descriptive name when prompted). Review the generated SQL in `prisma/migrations`. 310 | 4. **Apply Migrations:** `npm run db:migrations:apply` 311 | 5. **Generate Prisma Client:** `npm run db:gen` (Often run automatically). 312 | 6. **Reset Database (Caution!):** `npm run db:reset` 313 | 7. **Push Schema (Dev Only!):** `npm run db:push` (Directly syncs schema, bypasses migrations). 314 | 315 | > **Note:** For a typical development workflow, you will mostly use `npm run db:migrations:create` and `npm run db:migrations:apply`. 316 | > 317 | > For more details, visit the [Prisma Documentation](https://www.prisma.io/docs/). 318 | 319 | ## Code Quality 320 | 321 | > **TL;DR:** Run `npm run check` (linter + type-check) before **every** commit. 322 | 323 | A Lefthook pre-commit hook is configured (`lefthook.yml`) to run checks automatically. 324 | 325 | ### Linting & Formatting 326 | 327 | - **Check code (BiomeJS):** `npm run lint` 328 | - **Fix code (BiomeJS):** `npm run lint:fix` 329 | - **Check TypeScript types:** `npm run ts:check` 330 | - **Run all checks:** `npm run check` 331 | 332 | ### Testing 333 | 334 | - **Run unit tests:** `npm run test` 335 | - **Run unit tests (watch mode):** `npm run test:watch` 336 | - **Run e2e tests:** `npm run test:e2e` (Requires running app/db) 337 | - **Generate coverage report:** `npm run test:cov` 338 | 339 | ## Configuration 340 | 341 | Configuration is managed via environment variables loaded by `@nestjs/config` from a `.env` file. 342 | 343 | - Copy `.env.example` to `.env`. 344 | - Fill in your specific values in `.env`. 345 | 346 | ### Key Environment Variables 347 | 348 | These are some of the most important variables in `.env.example` to configure: 349 | 350 | - `NODE_ENV`: Set to `development` or `production`. 351 | - `PORT`: The port the NestJS application will listen on. 352 | - `OIDC_ISSUER`: The URL of the OIDC provider (e.g., Logto). 353 | - `OIDC_AUDIENCE`: The API identifier defined in the OIDC provider. 354 | - `OIDC_JWKS_URI`: The URL to the JSON Web Key Set (JWKS) of the provider. 355 | - `DATABASE_URL`: The full connection string for PostgreSQL. 356 | - `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_USER`, `DATABASE_PASSWORD`, `DATABASE_NAME`: Individual database connection parameters. 357 | - `REDIS_HOST`, `REDIS_PORT`, `REDIS_PASSWORD`: Redis connection details. 358 | - `MAILER_...`: Email sending configuration (SMTP details). 359 | - `DB_ADMIN_PORT`, `DB_ADMIN_EMAIL`, `DB_ADMIN_PASSWORD`: pgAdmin access details. 360 | - `REDIS_ADMIN_PORT`, `REDIS_ADMIN_USER`, `REDIS_ADMIN_PASSWORD`: Redis Commander access details. 361 | - `PRISMA_STUDIO_LOGIN`, `PRISMA_STUDIO_PASSWORD`: Credentials for the Prisma Studio web UI (`/studio`). 362 | - `BULL_BOARD_LOGIN`, `BULL_BOARD_PASSWORD`: Credentials for the Bull Board web UI (`/board`). 363 | - `LOGS_ADMIN_PANEL_USER`, `LOGS_ADMIN_PANEL_PASSWORD`: Credentials for the protected log viewer (`/logs`). 364 | 365 | *(Refer to `.env.example` for the full list and `src/config/` for validation schemas).* 366 | 367 | ## Internationalization (i18n) 368 | 369 | The project uses `nestjs-i18n` for handling multiple languages. 370 | 371 | - Language files (JSON format) are located in `src/i18n/`. 372 | - Add new language directories (e.g., `src/i18n/de/`) and translation files as needed. 373 | 374 | ## Contributing 375 | 376 | Contributions, issues, and feature requests are welcome! 377 | Feel free to check the [issues page](https://github.com/uxname/liteend/issues). 378 | 379 | ## Show Your Support 380 | 381 | Give a ⭐️ if this project helped you! 382 | 383 | ## License 384 | 385 | Copyright © 2023 [uxname](https://github.com/uxname). 386 | This project is [MIT](LICENSE) licensed. 387 | -------------------------------------------------------------------------------- /src/common/logger-serve/logger-ui.html.ts: -------------------------------------------------------------------------------- 1 | export const LOGGER_UI_HTML = ` 2 | 3 | 4 | 5 | 6 | 7 | LiteEnd Log Viewer 8 | 9 | 22 | 30 | 31 | 32 |
33 | 34 | 305 | 306 | 307 | `; 308 | --------------------------------------------------------------------------------