mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-14 00:32:35 +10:00
refactor(v4.0.0-alpha): beginning of a new era
This commit is contained in:
18
apps/server/.eslintrc.json
Normal file
18
apps/server/.eslintrc.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
apps/server/jest.config.ts
Normal file
11
apps/server/jest.config.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: "server",
|
||||
preset: "../../jest.preset.js",
|
||||
testEnvironment: "node",
|
||||
transform: {
|
||||
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
|
||||
},
|
||||
moduleFileExtensions: ["ts", "js", "html"],
|
||||
coverageDirectory: "../../coverage/apps/server",
|
||||
};
|
||||
65
apps/server/project.json
Normal file
65
apps/server/project.json
Normal file
@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "server",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/server/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"defaultConfiguration": "production",
|
||||
"dependsOn": ["^build"],
|
||||
"options": {
|
||||
"target": "node",
|
||||
"compiler": "tsc",
|
||||
"outputPath": "dist/apps/server",
|
||||
"main": "apps/server/src/main.ts",
|
||||
"tsConfig": "apps/server/tsconfig.app.json",
|
||||
"assets": ["apps/server/src/assets"],
|
||||
"isolatedConfig": true,
|
||||
"webpackConfig": "apps/server/webpack.config.js"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {},
|
||||
"production": {}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/js:node",
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"buildTarget": "server:build"
|
||||
},
|
||||
"configurations": {
|
||||
"development": {
|
||||
"buildTarget": "server:build:development"
|
||||
},
|
||||
"production": {
|
||||
"buildTarget": "server:build:production"
|
||||
}
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/server/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "apps/server/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
},
|
||||
"configurations": {
|
||||
"ci": {
|
||||
"ci": true,
|
||||
"codeCoverage": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["backend"]
|
||||
}
|
||||
65
apps/server/src/app.module.ts
Normal file
65
apps/server/src/app.module.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { HttpException, Module } from "@nestjs/common";
|
||||
import { APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
|
||||
import { ServeStaticModule } from "@nestjs/serve-static";
|
||||
import { RavenInterceptor, RavenModule } from "nest-raven";
|
||||
import { ZodSerializerInterceptor, ZodValidationPipe } from "nestjs-zod";
|
||||
import { join } from "path";
|
||||
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { CacheModule } from "./cache/cache.module";
|
||||
import { ConfigModule } from "./config/config.module";
|
||||
import { DatabaseModule } from "./database/database.module";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { MailModule } from "./mail/mail.module";
|
||||
import { PrinterModule } from "./printer/printer.module";
|
||||
import { ResumeModule } from "./resume/resume.module";
|
||||
import { StorageModule } from "./storage/storage.module";
|
||||
import { UserModule } from "./user/user.module";
|
||||
import { UtilsModule } from "./utils/utils.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Core Modules
|
||||
ConfigModule,
|
||||
DatabaseModule,
|
||||
MailModule,
|
||||
RavenModule,
|
||||
CacheModule,
|
||||
UtilsModule,
|
||||
HealthModule,
|
||||
ServeStaticModule.forRoot({
|
||||
rootPath: join(__dirname, "..", "client"),
|
||||
exclude: ["/api*", "/docs*"],
|
||||
}),
|
||||
|
||||
// Feature Modules
|
||||
AuthModule.register(),
|
||||
UserModule,
|
||||
ResumeModule,
|
||||
StorageModule,
|
||||
PrinterModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_PIPE,
|
||||
useClass: ZodValidationPipe,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ZodSerializerInterceptor,
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useValue: new RavenInterceptor({
|
||||
filters: [
|
||||
// Filter all HttpException with status code <= 500
|
||||
{
|
||||
type: HttpException,
|
||||
filter: (exception: HttpException) => exception.getStatus() < 500,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
0
apps/server/src/assets/.gitkeep
Normal file
0
apps/server/src/assets/.gitkeep
Normal file
BIN
apps/server/src/assets/fonts/computer-modern/bold.ttf
Normal file
BIN
apps/server/src/assets/fonts/computer-modern/bold.ttf
Normal file
Binary file not shown.
BIN
apps/server/src/assets/fonts/computer-modern/italic.ttf
Normal file
BIN
apps/server/src/assets/fonts/computer-modern/italic.ttf
Normal file
Binary file not shown.
BIN
apps/server/src/assets/fonts/computer-modern/regular.ttf
Normal file
BIN
apps/server/src/assets/fonts/computer-modern/regular.ttf
Normal file
Binary file not shown.
320
apps/server/src/auth/auth.controller.ts
Normal file
320
apps/server/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,320 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
InternalServerErrorException,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
authResponseSchema,
|
||||
backupCodesSchema,
|
||||
ForgotPasswordDto,
|
||||
MessageDto,
|
||||
messageSchema,
|
||||
RegisterDto,
|
||||
ResetPasswordDto,
|
||||
TwoFactorBackupDto,
|
||||
TwoFactorDto,
|
||||
UpdatePasswordDto,
|
||||
UserDto,
|
||||
userSchema,
|
||||
UserWithSecrets,
|
||||
} from "@reactive-resume/dto";
|
||||
import type { Response } from "express";
|
||||
import { ZodSerializerDto } from "nestjs-zod";
|
||||
|
||||
import { ErrorMessage } from "../constants/error-message";
|
||||
import { User } from "../user/decorators/user.decorator";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { GitHubGuard } from "./guards/github.guard";
|
||||
import { GoogleGuard } from "./guards/google.guard";
|
||||
import { JwtGuard } from "./guards/jwt.guard";
|
||||
import { LocalGuard } from "./guards/local.guard";
|
||||
import { RefreshGuard } from "./guards/refresh.guard";
|
||||
import { TwoFactorGuard } from "./guards/two-factor.guard";
|
||||
import { getCookieOptions } from "./utils/cookie";
|
||||
import { payloadSchema } from "./utils/payload";
|
||||
|
||||
@ApiTags("Authentication")
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly utils: UtilsService,
|
||||
) {}
|
||||
|
||||
private async exchangeToken(id: string, email: string, isTwoFactorAuth: boolean = false) {
|
||||
try {
|
||||
const payload = payloadSchema.parse({ id, isTwoFactorAuth });
|
||||
|
||||
const accessToken = this.authService.generateToken("access", payload);
|
||||
const refreshToken = this.authService.generateToken("refresh", payload);
|
||||
|
||||
// Set Refresh Token in Database
|
||||
await this.authService.setRefreshToken(email, refreshToken);
|
||||
|
||||
return { accessToken, refreshToken };
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(error, ErrorMessage.SomethingWentWrong);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleAuthenticationResponse(
|
||||
user: UserWithSecrets,
|
||||
response: Response,
|
||||
isTwoFactorAuth: boolean = false,
|
||||
redirect: boolean = false,
|
||||
) {
|
||||
let status = "authenticated";
|
||||
|
||||
const redirectUrl = new URL(`${this.utils.getUrl()}/auth/callback`);
|
||||
|
||||
const { accessToken, refreshToken } = await this.exchangeToken(
|
||||
user.id,
|
||||
user.email,
|
||||
isTwoFactorAuth,
|
||||
);
|
||||
|
||||
response.cookie("Authentication", accessToken, getCookieOptions("access"));
|
||||
response.cookie("Refresh", refreshToken, getCookieOptions("refresh"));
|
||||
|
||||
if (user.twoFactorEnabled && !isTwoFactorAuth) status = "2fa_required";
|
||||
|
||||
const responseData = authResponseSchema.parse({ status, user });
|
||||
|
||||
redirectUrl.searchParams.set("status", status);
|
||||
|
||||
if (redirect) response.redirect(redirectUrl.toString());
|
||||
else response.status(200).send(responseData);
|
||||
}
|
||||
|
||||
@Post("register")
|
||||
async register(@Body() registerDto: RegisterDto, @Res({ passthrough: true }) response: Response) {
|
||||
const user = await this.authService.register(registerDto);
|
||||
|
||||
return this.handleAuthenticationResponse(user, response);
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
@UseGuards(LocalGuard)
|
||||
async login(@User() user: UserWithSecrets, @Res({ passthrough: true }) response: Response) {
|
||||
return this.handleAuthenticationResponse(user, response);
|
||||
}
|
||||
|
||||
@ApiTags("OAuth", "GitHub")
|
||||
@Get("github")
|
||||
@UseGuards(GitHubGuard)
|
||||
githubLogin() {
|
||||
return;
|
||||
}
|
||||
|
||||
@ApiTags("OAuth", "GitHub")
|
||||
@Get("github/callback")
|
||||
@UseGuards(GitHubGuard)
|
||||
async githubCallback(
|
||||
@User() user: UserWithSecrets,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.handleAuthenticationResponse(user, response, false, true);
|
||||
}
|
||||
|
||||
@ApiTags("OAuth", "Google")
|
||||
@Get("google")
|
||||
@UseGuards(GoogleGuard)
|
||||
googleLogin() {
|
||||
return;
|
||||
}
|
||||
|
||||
@ApiTags("OAuth", "Google")
|
||||
@Get("google/callback")
|
||||
@UseGuards(GoogleGuard)
|
||||
async googleCallback(
|
||||
@User() user: UserWithSecrets,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.handleAuthenticationResponse(user, response, false, true);
|
||||
}
|
||||
|
||||
@Post("refresh")
|
||||
@UseGuards(RefreshGuard)
|
||||
async refresh(@User() user: UserWithSecrets, @Res({ passthrough: true }) response: Response) {
|
||||
return this.handleAuthenticationResponse(user, response, true);
|
||||
}
|
||||
|
||||
@Patch("password")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async updatePassword(@User("email") email: string, @Body() { password }: UpdatePasswordDto) {
|
||||
await this.authService.updatePassword(email, password);
|
||||
|
||||
return { message: "Your password has been successfully updated." };
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
async logout(@User() user: UserWithSecrets, @Res({ passthrough: true }) response: Response) {
|
||||
await this.authService.setRefreshToken(user.email, null);
|
||||
|
||||
response.clearCookie("Authentication");
|
||||
response.clearCookie("Refresh");
|
||||
|
||||
const data = messageSchema.parse({ message: "You have been logged out, tschüss!" });
|
||||
response.status(200).send(data);
|
||||
}
|
||||
|
||||
// Two Factor Authentication Flows
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@Post("2fa/setup")
|
||||
@UseGuards(JwtGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async setup2FASecret(@User("email") email: string) {
|
||||
return this.authService.setup2FASecret(email);
|
||||
}
|
||||
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@HttpCode(200)
|
||||
@Post("2fa/enable")
|
||||
@UseGuards(JwtGuard)
|
||||
async enable2FA(
|
||||
@User("id") id: string,
|
||||
@User("email") email: string,
|
||||
@Body() { code }: TwoFactorDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const { backupCodes } = await this.authService.enable2FA(email, code);
|
||||
|
||||
const { accessToken, refreshToken } = await this.exchangeToken(id, email, true);
|
||||
|
||||
response.cookie("Authentication", accessToken, getCookieOptions("access"));
|
||||
response.cookie("Refresh", refreshToken, getCookieOptions("refresh"));
|
||||
|
||||
const data = backupCodesSchema.parse({ backupCodes });
|
||||
response.status(200).send(data);
|
||||
}
|
||||
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@HttpCode(200)
|
||||
@Post("2fa/disable")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async disable2FA(@User("email") email: string) {
|
||||
await this.authService.disable2FA(email);
|
||||
|
||||
return { message: "Two-factor authentication has been successfully disabled on your account." };
|
||||
}
|
||||
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@HttpCode(200)
|
||||
@Post("2fa/verify")
|
||||
@UseGuards(JwtGuard)
|
||||
@ZodSerializerDto(UserDto)
|
||||
async verify2FACode(
|
||||
@User() user: UserWithSecrets,
|
||||
@Body() { code }: TwoFactorDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
await this.authService.verify2FACode(user.email, code);
|
||||
|
||||
const { accessToken, refreshToken } = await this.exchangeToken(user.id, user.email, true);
|
||||
|
||||
response.cookie("Authentication", accessToken, getCookieOptions("access"));
|
||||
response.cookie("Refresh", refreshToken, getCookieOptions("refresh"));
|
||||
|
||||
response.status(200).send(userSchema.parse(user));
|
||||
}
|
||||
|
||||
@ApiTags("Two-Factor Auth")
|
||||
@HttpCode(200)
|
||||
@Post("2fa/backup")
|
||||
@UseGuards(JwtGuard)
|
||||
@ZodSerializerDto(UserDto)
|
||||
async useBackup2FACode(
|
||||
@User("id") id: string,
|
||||
@User("email") email: string,
|
||||
@Body() { code }: TwoFactorBackupDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
const user = await this.authService.useBackup2FACode(email, code);
|
||||
|
||||
return this.handleAuthenticationResponse(user, response, true);
|
||||
}
|
||||
|
||||
// Password Recovery Flows
|
||||
@ApiTags("Password Reset")
|
||||
@HttpCode(200)
|
||||
@Post("forgot-password")
|
||||
async forgotPassword(@Body() { email }: ForgotPasswordDto) {
|
||||
try {
|
||||
await this.authService.forgotPassword(email);
|
||||
} catch (error) {
|
||||
// pass
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
"A password reset link should have been sent to your inbox, if an account existed with the email you provided.",
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags("Password Reset")
|
||||
@HttpCode(200)
|
||||
@Post("reset-password")
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async resetPassword(@Body() { token, password }: ResetPasswordDto) {
|
||||
try {
|
||||
await this.authService.resetPassword(token, password);
|
||||
|
||||
return { message: "Your password has been successfully reset." };
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidResetToken);
|
||||
}
|
||||
}
|
||||
|
||||
// Email Verification Flows
|
||||
@ApiTags("Email Verification")
|
||||
@Post("verify-email")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async verifyEmail(
|
||||
@User("id") id: string,
|
||||
@User("emailVerified") emailVerified: boolean,
|
||||
@Query("token") token: string,
|
||||
) {
|
||||
if (!token) throw new BadRequestException(ErrorMessage.InvalidVerificationToken);
|
||||
|
||||
if (emailVerified) {
|
||||
throw new BadRequestException(ErrorMessage.EmailAlreadyVerified);
|
||||
}
|
||||
|
||||
await this.authService.verifyEmail(id, token);
|
||||
|
||||
return { message: "Your email has been successfully verified." };
|
||||
}
|
||||
|
||||
@ApiTags("Email Verification")
|
||||
@Post("verify-email/resend")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async resendVerificationEmail(
|
||||
@User("email") email: string,
|
||||
@User("emailVerified") emailVerified: boolean,
|
||||
) {
|
||||
if (emailVerified) {
|
||||
throw new BadRequestException(ErrorMessage.EmailAlreadyVerified);
|
||||
}
|
||||
|
||||
await this.authService.sendVerificationEmail(email);
|
||||
|
||||
return {
|
||||
message: "You should have received a new email with a link to verify your email address.",
|
||||
};
|
||||
}
|
||||
}
|
||||
70
apps/server/src/auth/auth.module.ts
Normal file
70
apps/server/src/auth/auth.module.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { DynamicModule, Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
import { MailModule } from "../mail/mail.module";
|
||||
import { UserModule } from "../user/user.module";
|
||||
import { UserService } from "../user/user.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { DummyStrategy } from "./strategy/dummy.strategy";
|
||||
import { GitHubStrategy } from "./strategy/github.strategy";
|
||||
import { GoogleStrategy } from "./strategy/google.strategy";
|
||||
import { JwtStrategy } from "./strategy/jwt.strategy";
|
||||
import { LocalStrategy } from "./strategy/local.strategy";
|
||||
import { RefreshStrategy } from "./strategy/refresh.strategy";
|
||||
import { TwoFactorStrategy } from "./strategy/two-factor.strategy";
|
||||
|
||||
@Module({})
|
||||
export class AuthModule {
|
||||
static register(): DynamicModule {
|
||||
return {
|
||||
module: AuthModule,
|
||||
imports: [PassportModule, JwtModule, UserModule, MailModule],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
AuthService,
|
||||
LocalStrategy,
|
||||
JwtStrategy,
|
||||
RefreshStrategy,
|
||||
TwoFactorStrategy,
|
||||
|
||||
// OAuth2 Strategies
|
||||
{
|
||||
provide: GoogleStrategy,
|
||||
inject: [ConfigService, UserService],
|
||||
useFactory: (configService: ConfigService<Config>, userService: UserService) => {
|
||||
try {
|
||||
const clientID = configService.getOrThrow("GOOGLE_CLIENT_ID");
|
||||
const clientSecret = configService.getOrThrow("GOOGLE_CLIENT_SECRET");
|
||||
const callbackURL = configService.getOrThrow("GOOGLE_CALLBACK_URL");
|
||||
|
||||
return new GoogleStrategy(clientID, clientSecret, callbackURL, userService);
|
||||
} catch (error) {
|
||||
return new DummyStrategy();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
provide: GitHubStrategy,
|
||||
inject: [ConfigService, UserService],
|
||||
useFactory: (configService: ConfigService<Config>, userService: UserService) => {
|
||||
try {
|
||||
const clientID = configService.getOrThrow("GITHUB_CLIENT_ID");
|
||||
const clientSecret = configService.getOrThrow("GITHUB_CLIENT_SECRET");
|
||||
const callbackURL = configService.getOrThrow("GITHUB_CALLBACK_URL");
|
||||
|
||||
return new GitHubStrategy(clientID, clientSecret, callbackURL, userService);
|
||||
} catch (error) {
|
||||
return new DummyStrategy();
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AuthService],
|
||||
};
|
||||
}
|
||||
}
|
||||
321
apps/server/src/auth/auth.service.ts
Normal file
321
apps/server/src/auth/auth.service.ts
Normal file
@ -0,0 +1,321 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { LoginDto, RegisterDto } from "@reactive-resume/dto";
|
||||
import * as bcryptjs from "bcryptjs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
import { ErrorMessage } from "../constants/error-message";
|
||||
import { MailService } from "../mail/mail.service";
|
||||
import { UserService } from "../user/user.service";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
import { Payload } from "./utils/payload";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly userService: UserService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly utils: UtilsService,
|
||||
) {}
|
||||
|
||||
private hash(password: string): Promise<string> {
|
||||
return bcryptjs.hash(password, 10);
|
||||
}
|
||||
|
||||
private compare(password: string, hash: string): Promise<boolean> {
|
||||
return bcryptjs.compare(password, hash);
|
||||
}
|
||||
|
||||
private async validatePassword(password: string, hashedPassword: string) {
|
||||
const isValid = await this.compare(password, hashedPassword);
|
||||
|
||||
if (!isValid) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
generateToken(grantType: "access" | "refresh" | "reset" | "verification", payload?: Payload) {
|
||||
switch (grantType) {
|
||||
case "access":
|
||||
if (!payload) throw new InternalServerErrorException("InvalidTokenPayload");
|
||||
return this.jwtService.sign(payload, {
|
||||
secret: this.configService.getOrThrow("ACCESS_TOKEN_SECRET"),
|
||||
expiresIn: "15m", // 15 minutes
|
||||
});
|
||||
|
||||
case "refresh":
|
||||
if (!payload) throw new InternalServerErrorException("InvalidTokenPayload");
|
||||
return this.jwtService.sign(payload, {
|
||||
secret: this.configService.getOrThrow("REFRESH_TOKEN_SECRET"),
|
||||
expiresIn: "2d", // 2 days
|
||||
});
|
||||
|
||||
case "reset":
|
||||
case "verification":
|
||||
return randomBytes(32).toString("base64url");
|
||||
|
||||
default:
|
||||
throw new InternalServerErrorException("InvalidGrantType: " + grantType);
|
||||
}
|
||||
}
|
||||
|
||||
async setRefreshToken(email: string, token: string | null) {
|
||||
await this.userService.updateByEmail(email, {
|
||||
secrets: {
|
||||
update: {
|
||||
refreshToken: token,
|
||||
lastSignedIn: token ? new Date() : undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async validateRefreshToken(payload: Payload, token: string) {
|
||||
const user = await this.userService.findOneById(payload.id);
|
||||
const storedRefreshToken = user.secrets?.refreshToken;
|
||||
|
||||
if (!storedRefreshToken || storedRefreshToken !== token) throw new ForbiddenException();
|
||||
|
||||
if (!user.twoFactorEnabled) return user;
|
||||
|
||||
if (payload.isTwoFactorAuth) return user;
|
||||
}
|
||||
|
||||
async register(registerDto: RegisterDto) {
|
||||
const hashedPassword = await this.hash(registerDto.password);
|
||||
|
||||
try {
|
||||
const user = await this.userService.create({
|
||||
name: registerDto.name,
|
||||
email: registerDto.email,
|
||||
username: registerDto.username,
|
||||
language: registerDto.language,
|
||||
provider: "email",
|
||||
emailVerified: false, // Set to true if you don't want to verify user's email
|
||||
secrets: { create: { password: hashedPassword } },
|
||||
});
|
||||
|
||||
// Do not `await` this function, otherwise the user will have to wait for the email to be sent before the response is returned
|
||||
this.sendVerificationEmail(user.email);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
async authenticate({ identifier, password }: LoginDto) {
|
||||
try {
|
||||
const user = await this.userService.findOneByIdentifier(identifier);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidCredentials);
|
||||
}
|
||||
|
||||
if (!user.secrets?.password) {
|
||||
throw new BadRequestException(ErrorMessage.OAuthUser);
|
||||
}
|
||||
|
||||
await this.validatePassword(password, user.secrets?.password);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
// Password Reset Flows
|
||||
async forgotPassword(email: string) {
|
||||
const token = this.generateToken("reset");
|
||||
|
||||
await this.userService.updateByEmail(email, {
|
||||
secrets: { update: { resetToken: token } },
|
||||
});
|
||||
|
||||
const url = `${this.utils.getUrl()}/auth/reset-password?token=${token}`;
|
||||
const subject = "Reset your Reactive Resume password";
|
||||
const text = `Please click on the link below to reset your password:\n\n${url}`;
|
||||
|
||||
await this.mailService.sendEmail({ to: email, subject, text });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async updatePassword(email: string, password: string) {
|
||||
const hashedPassword = await this.hash(password);
|
||||
|
||||
await this.userService.updateByEmail(email, {
|
||||
secrets: { update: { password: hashedPassword } },
|
||||
});
|
||||
}
|
||||
|
||||
async resetPassword(token: string, password: string) {
|
||||
const hashedPassword = await this.hash(password);
|
||||
|
||||
await this.userService.updateByResetToken(token, {
|
||||
resetToken: null,
|
||||
password: hashedPassword,
|
||||
});
|
||||
}
|
||||
|
||||
// Email Verification Flows
|
||||
async sendVerificationEmail(email: string) {
|
||||
try {
|
||||
const token = this.generateToken("verification");
|
||||
|
||||
// Set the verification token in the database
|
||||
await this.userService.updateByEmail(email, {
|
||||
secrets: { update: { verificationToken: token } },
|
||||
});
|
||||
|
||||
const url = `${this.utils.getUrl()}/auth/verify-email?token=${token}`;
|
||||
const subject = "Verify your email address";
|
||||
const text = `Please verify your email address by clicking on the link below:\n\n${url}`;
|
||||
|
||||
await this.mailService.sendEmail({ to: email, subject, text });
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
async verifyEmail(id: string, token: string) {
|
||||
const user = await this.userService.findOneById(id);
|
||||
|
||||
const storedToken = user.secrets?.verificationToken;
|
||||
|
||||
if (!storedToken || storedToken !== token) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidVerificationToken);
|
||||
}
|
||||
|
||||
await this.userService.updateByEmail(user.email, {
|
||||
emailVerified: true,
|
||||
secrets: { update: { verificationToken: null } },
|
||||
});
|
||||
}
|
||||
|
||||
// Two Factor Authentication Flows
|
||||
async setup2FASecret(email: string) {
|
||||
// If the user already has 2FA enabled, throw an error
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException(ErrorMessage.TwoFactorAlreadyEnabled);
|
||||
}
|
||||
|
||||
const secret = authenticator.generateSecret();
|
||||
const uri = authenticator.keyuri(email, "Reactive Resume", secret);
|
||||
|
||||
await this.userService.updateByEmail(email, {
|
||||
secrets: { update: { twoFactorSecret: secret } },
|
||||
});
|
||||
|
||||
return { message: uri };
|
||||
}
|
||||
|
||||
async enable2FA(email: string, code: string) {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
// If the user already has 2FA enabled, throw an error
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException(ErrorMessage.TwoFactorAlreadyEnabled);
|
||||
}
|
||||
|
||||
// If the user doesn't have a 2FA secret set, throw an error
|
||||
if (!user.secrets?.twoFactorSecret) {
|
||||
throw new BadRequestException(ErrorMessage.TwoFactorNotEnabled);
|
||||
}
|
||||
|
||||
const verified = authenticator.verify({
|
||||
secret: user.secrets?.twoFactorSecret,
|
||||
token: code,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
// Create backup codes and store them in the database
|
||||
const backupCodes = Array.from({ length: 8 }, () => randomBytes(5).toString("hex"));
|
||||
|
||||
await this.userService.updateByEmail(email, {
|
||||
twoFactorEnabled: true,
|
||||
secrets: { update: { twoFactorBackupCodes: backupCodes } },
|
||||
});
|
||||
|
||||
return { backupCodes };
|
||||
}
|
||||
|
||||
async disable2FA(email: string) {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
// If the user doesn't have 2FA enabled, throw an error
|
||||
if (!user.twoFactorEnabled) {
|
||||
throw new BadRequestException(ErrorMessage.TwoFactorNotEnabled);
|
||||
}
|
||||
|
||||
await this.userService.updateByEmail(email, {
|
||||
twoFactorEnabled: false,
|
||||
secrets: { update: { twoFactorSecret: null, twoFactorBackupCodes: [] } },
|
||||
});
|
||||
}
|
||||
|
||||
async verify2FACode(email: string, code: string) {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
// If the user doesn't have 2FA enabled, or does not have a 2FA secret set, throw an error
|
||||
if (!user.twoFactorEnabled || !user.secrets?.twoFactorSecret) {
|
||||
throw new BadRequestException(ErrorMessage.TwoFactorNotEnabled);
|
||||
}
|
||||
|
||||
const verified = authenticator.verify({
|
||||
secret: user.secrets?.twoFactorSecret,
|
||||
token: code,
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidTwoFactorCode);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async useBackup2FACode(email: string, code: string) {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
// If the user doesn't have 2FA enabled, or does not have a 2FA secret set, throw an error
|
||||
if (!user.twoFactorEnabled || !user.secrets?.twoFactorSecret) {
|
||||
throw new BadRequestException(ErrorMessage.TwoFactorNotEnabled);
|
||||
}
|
||||
|
||||
const verified = user.secrets?.twoFactorBackupCodes.includes(code);
|
||||
|
||||
if (!verified) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidTwoFactorBackupCode);
|
||||
}
|
||||
|
||||
// Remove the used backup code from the database
|
||||
const backupCodes = user.secrets?.twoFactorBackupCodes.filter((c) => c !== code);
|
||||
await this.userService.updateByEmail(email, {
|
||||
secrets: { update: { twoFactorBackupCodes: backupCodes } },
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
}
|
||||
5
apps/server/src/auth/guards/github.guard.ts
Normal file
5
apps/server/src/auth/guards/github.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class GitHubGuard extends AuthGuard("github") {}
|
||||
5
apps/server/src/auth/guards/google.guard.ts
Normal file
5
apps/server/src/auth/guards/google.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleGuard extends AuthGuard("google") {}
|
||||
5
apps/server/src/auth/guards/jwt.guard.ts
Normal file
5
apps/server/src/auth/guards/jwt.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class JwtGuard extends AuthGuard("jwt") {}
|
||||
5
apps/server/src/auth/guards/local.guard.ts
Normal file
5
apps/server/src/auth/guards/local.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class LocalGuard extends AuthGuard("local") {}
|
||||
10
apps/server/src/auth/guards/optional.guard.ts
Normal file
10
apps/server/src/auth/guards/optional.guard.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { UserDto } from "@reactive-resume/dto";
|
||||
|
||||
@Injectable()
|
||||
export class OptionalGuard extends AuthGuard("two-factor") {
|
||||
handleRequest<TUser = UserDto>(error: Error, user: TUser): TUser {
|
||||
return user;
|
||||
}
|
||||
}
|
||||
5
apps/server/src/auth/guards/refresh.guard.ts
Normal file
5
apps/server/src/auth/guards/refresh.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class RefreshGuard extends AuthGuard("refresh") {}
|
||||
5
apps/server/src/auth/guards/two-factor.guard.ts
Normal file
5
apps/server/src/auth/guards/two-factor.guard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorGuard extends AuthGuard("two-factor") {}
|
||||
14
apps/server/src/auth/strategy/dummy.strategy.ts
Normal file
14
apps/server/src/auth/strategy/dummy.strategy.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport";
|
||||
|
||||
@Injectable()
|
||||
export class DummyStrategy extends PassportStrategy(Strategy, "dummy") {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
this.fail();
|
||||
}
|
||||
}
|
||||
61
apps/server/src/auth/strategy/github.strategy.ts
Normal file
61
apps/server/src/auth/strategy/github.strategy.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { processUsername } from "@reactive-resume/utils";
|
||||
import { Profile, Strategy, StrategyOptions } from "passport-github2";
|
||||
|
||||
import { ErrorMessage } from "@/server/constants/error-message";
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
|
||||
@Injectable()
|
||||
export class GitHubStrategy extends PassportStrategy(Strategy, "github") {
|
||||
constructor(
|
||||
readonly clientID: string,
|
||||
readonly clientSecret: string,
|
||||
readonly callbackURL: string,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super({ clientID, clientSecret, callbackURL, scope: ["user:email"] } as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
done: (err?: string | Error | null, user?: Express.User, info?: unknown) => void,
|
||||
) {
|
||||
const { displayName, emails, photos, username } = profile;
|
||||
|
||||
const email = emails?.[0].value ?? `${username}@github.com`;
|
||||
const picture = photos?.[0].value;
|
||||
|
||||
let user: User | null = null;
|
||||
|
||||
if (!email) throw new BadRequestException();
|
||||
|
||||
try {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
try {
|
||||
user = await this.userService.create({
|
||||
email,
|
||||
picture,
|
||||
language: "en",
|
||||
name: displayName,
|
||||
provider: "github",
|
||||
emailVerified: true, // auto-verify emails
|
||||
username: processUsername(username ?? email.split("@")[0]),
|
||||
secrets: { create: {} },
|
||||
});
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
apps/server/src/auth/strategy/google.strategy.ts
Normal file
61
apps/server/src/auth/strategy/google.strategy.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { processUsername } from "@reactive-resume/utils";
|
||||
import { Profile, Strategy, StrategyOptions, VerifyCallback } from "passport-google-oauth20";
|
||||
|
||||
import { ErrorMessage } from "@/server/constants/error-message";
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
|
||||
constructor(
|
||||
readonly clientID: string,
|
||||
readonly clientSecret: string,
|
||||
readonly callbackURL: string,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
super({ clientID, clientSecret, callbackURL, scope: ["email", "profile"] } as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(
|
||||
_accessToken: string,
|
||||
_refreshToken: string,
|
||||
profile: Profile,
|
||||
done: VerifyCallback,
|
||||
) {
|
||||
const { displayName, emails, photos, username } = profile;
|
||||
|
||||
const email = emails?.[0].value ?? `${username}@google.com`;
|
||||
const picture = photos?.[0].value;
|
||||
|
||||
let user: User | null = null;
|
||||
|
||||
if (!email) throw new BadRequestException();
|
||||
|
||||
try {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
|
||||
if (!user) throw new UnauthorizedException();
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
try {
|
||||
user = await this.userService.create({
|
||||
email,
|
||||
picture,
|
||||
language: "en",
|
||||
name: displayName,
|
||||
provider: "google",
|
||||
emailVerified: true, // auto-verify emails
|
||||
username: processUsername(username ?? email.split("@")[0]),
|
||||
secrets: { create: {} },
|
||||
});
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/server/src/auth/strategy/jwt.strategy.ts
Normal file
30
apps/server/src/auth/strategy/jwt.strategy.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import type { Request } from "express";
|
||||
import { ExtractJwt, Strategy, StrategyOptions } from "passport-jwt";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
|
||||
import { Payload } from "../utils/payload";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Authentication];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.get<string>("ACCESS_TOKEN_SECRET"),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors(extractors),
|
||||
ignoreExpiration: false,
|
||||
} as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(payload: Payload) {
|
||||
return this.userService.findOneById(payload.id);
|
||||
}
|
||||
}
|
||||
22
apps/server/src/auth/strategy/local.strategy.ts
Normal file
22
apps/server/src/auth/strategy/local.strategy.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { IStrategyOptions, Strategy } from "passport-local";
|
||||
|
||||
import { ErrorMessage } from "@/server/constants/error-message";
|
||||
|
||||
import { AuthService } from "../auth.service";
|
||||
|
||||
@Injectable()
|
||||
export class LocalStrategy extends PassportStrategy(Strategy, "local") {
|
||||
constructor(private readonly authService: AuthService) {
|
||||
super({ usernameField: "identifier" } as IStrategyOptions);
|
||||
}
|
||||
|
||||
async validate(identifier: string, password: string) {
|
||||
try {
|
||||
return this.authService.authenticate({ identifier, password });
|
||||
} catch (error) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidCredentials);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
apps/server/src/auth/strategy/refresh.strategy.ts
Normal file
33
apps/server/src/auth/strategy/refresh.strategy.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import type { Request } from "express";
|
||||
import { ExtractJwt, Strategy, StrategyOptions } from "passport-jwt";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
|
||||
import { AuthService } from "../auth.service";
|
||||
import { Payload } from "../utils/payload";
|
||||
|
||||
@Injectable()
|
||||
export class RefreshStrategy extends PassportStrategy(Strategy, "refresh") {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Refresh];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.getOrThrow<string>("REFRESH_TOKEN_SECRET"),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors(extractors),
|
||||
passReqToCallback: true,
|
||||
ignoreExpiration: false,
|
||||
} as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(request: Request, payload: Payload) {
|
||||
const refreshToken = request.cookies?.Refresh;
|
||||
|
||||
return this.authService.validateRefreshToken(payload, refreshToken);
|
||||
}
|
||||
}
|
||||
35
apps/server/src/auth/strategy/two-factor.strategy.ts
Normal file
35
apps/server/src/auth/strategy/two-factor.strategy.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import type { Request } from "express";
|
||||
import { ExtractJwt, Strategy, StrategyOptions } from "passport-jwt";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
|
||||
import { Payload } from "../utils/payload";
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorStrategy extends PassportStrategy(Strategy, "two-factor") {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Authentication];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.get<string>("ACCESS_TOKEN_SECRET"),
|
||||
jwtFromRequest: ExtractJwt.fromExtractors(extractors),
|
||||
ignoreExpiration: false,
|
||||
} as StrategyOptions);
|
||||
}
|
||||
|
||||
async validate(payload: Payload) {
|
||||
const user = await this.userService.findOneById(payload.id);
|
||||
|
||||
// If the user has 2FA disabled, this will follow the same route as JWT Strategy
|
||||
if (!user.twoFactorEnabled) return user;
|
||||
|
||||
if (payload.isTwoFactorAuth) return user;
|
||||
}
|
||||
}
|
||||
26
apps/server/src/auth/utils/cookie.ts
Normal file
26
apps/server/src/auth/utils/cookie.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { InternalServerErrorException } from "@nestjs/common";
|
||||
import { CookieOptions } from "express";
|
||||
|
||||
export const getCookieOptions = (grantType: "access" | "refresh"): CookieOptions => {
|
||||
// Options For Access Token
|
||||
if (grantType === "access") {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: (process.env.PUBLIC_URL ?? "").includes("https://"),
|
||||
expires: new Date(Date.now() + 1000 * 60 * 15), // 15 minutes from now
|
||||
};
|
||||
}
|
||||
|
||||
// Options For Refresh Token
|
||||
if (grantType === "refresh") {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: (process.env.PUBLIC_URL ?? "").includes("https://"),
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 2), // 2 days from now
|
||||
};
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException("InvalidGrantType: " + grantType);
|
||||
};
|
||||
9
apps/server/src/auth/utils/payload.ts
Normal file
9
apps/server/src/auth/utils/payload.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { idSchema } from "@reactive-resume/schema";
|
||||
import { z } from "nestjs-zod/z";
|
||||
|
||||
export const payloadSchema = z.object({
|
||||
id: idSchema,
|
||||
isTwoFactorAuth: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type Payload = z.infer<typeof payloadSchema>;
|
||||
38
apps/server/src/cache/cache.module.ts
vendored
Normal file
38
apps/server/src/cache/cache.module.ts
vendored
Normal file
@ -0,0 +1,38 @@
|
||||
import { CacheModule as NestCacheModule } from "@nestjs/cache-manager";
|
||||
import { Logger, Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { RedisModule } from "@songkeys/nestjs-redis";
|
||||
import { redisStore } from "cache-manager-redis-yet";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
RedisModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService<Config>) => ({
|
||||
config: { url: configService.getOrThrow("REDIS_URL") },
|
||||
}),
|
||||
}),
|
||||
NestCacheModule.registerAsync({
|
||||
isGlobal: true,
|
||||
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService<Config>) => {
|
||||
const url = configService.get("REDIS_URL");
|
||||
|
||||
if (!url) {
|
||||
Logger.warn(
|
||||
"`REDIS_URL` was not set, using in-memory cache instead. This is not suitable for production.",
|
||||
"CacheModule",
|
||||
);
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
return { store: await redisStore({ url }) };
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class CacheModule {}
|
||||
15
apps/server/src/config/config.module.ts
Normal file
15
apps/server/src/config/config.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule as NestConfigModule } from "@nestjs/config";
|
||||
|
||||
import { configSchema } from "./schema";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NestConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
expandVariables: true,
|
||||
validate: configSchema.parse,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class ConfigModule {}
|
||||
56
apps/server/src/config/schema.ts
Normal file
56
apps/server/src/config/schema.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { z } from "nestjs-zod/z";
|
||||
|
||||
export const configSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "production"]).default("development"),
|
||||
|
||||
// Ports
|
||||
PORT: z.coerce.number().default(3000),
|
||||
|
||||
// Client Port & URL (only for development environments)
|
||||
__DEV__CLIENT_PORT: z.coerce.number().default(5173),
|
||||
__DEV__CLIENT_URL: z.string().url().default("http://localhost:5173"),
|
||||
|
||||
// URLs
|
||||
PUBLIC_URL: z.string().url(),
|
||||
STORAGE_URL: z.string().url(),
|
||||
CHROME_URL: z.string().url(),
|
||||
|
||||
// Database (Prisma)
|
||||
DATABASE_URL: z.string().url().startsWith("postgresql://"),
|
||||
|
||||
// Authentication Secrets
|
||||
ACCESS_TOKEN_SECRET: z.string(),
|
||||
REFRESH_TOKEN_SECRET: z.string(),
|
||||
|
||||
// Browser
|
||||
CHROME_TOKEN: z.string(),
|
||||
|
||||
// Mail Server
|
||||
SMTP_URL: z.string().url().startsWith("smtp://").optional(),
|
||||
|
||||
// Storage
|
||||
STORAGE_ENDPOINT: z.string(),
|
||||
STORAGE_PORT: z.coerce.number(),
|
||||
STORAGE_REGION: z.string().default("us-east-1"),
|
||||
STORAGE_BUCKET: z.string(),
|
||||
STORAGE_ACCESS_KEY: z.string(),
|
||||
STORAGE_SECRET_KEY: z.string(),
|
||||
|
||||
// Redis
|
||||
REDIS_URL: z.string().url().startsWith("redis://").optional(),
|
||||
|
||||
// Sentry
|
||||
SENTRY_DSN: z.string().url().optional(),
|
||||
|
||||
// GitHub (OAuth)
|
||||
GITHUB_CLIENT_ID: z.string().optional(),
|
||||
GITHUB_CLIENT_SECRET: z.string().optional(),
|
||||
GITHUB_CALLBACK_URL: z.string().url().optional(),
|
||||
|
||||
// Google (OAuth)
|
||||
GOOGLE_CLIENT_ID: z.string().optional(),
|
||||
GOOGLE_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_CALLBACK_URL: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
32
apps/server/src/constants/error-message.ts
Normal file
32
apps/server/src/constants/error-message.ts
Normal file
@ -0,0 +1,32 @@
|
||||
export const ErrorMessage = {
|
||||
InvalidCredentials: "It doesn't look like a user exists with the credentials you provided.",
|
||||
UserAlreadyExists: "A user with this email address and/or username already exists.",
|
||||
SecretsNotFound:
|
||||
'User does not have an associated "secrets" record. Please report this issue on GitHub.',
|
||||
OAuthUser:
|
||||
"This email address is associated with an OAuth account. Please sign in with your OAuth provider.",
|
||||
InvalidResetToken:
|
||||
"It looks like the reset token you provided is invalid. Please try restarting the password reset process again.",
|
||||
InvalidVerificationToken:
|
||||
"It looks like the verification token you provided is invalid. Please try restarting the verification process again.",
|
||||
EmailAlreadyVerified: "It looks like your email address has already been verified.",
|
||||
TwoFactorNotEnabled: "Two-factor authentication is not enabled for this account.",
|
||||
TwoFactorAlreadyEnabled: "Two-factor authentication is already enabled for this account.",
|
||||
InvalidTwoFactorCode:
|
||||
"It looks like the two-factor authentication code you provided is invalid. Please try again.",
|
||||
InvalidTwoFactorBackupCode:
|
||||
"It looks like the backup code you provided is invalid or used. Please try again.",
|
||||
InvalidBrowserConnection:
|
||||
"There was an error connecting to the browser. Please make sure `chrome` is running and reachable.",
|
||||
ResumeSlugAlreadyExists:
|
||||
"A resume with this slug already exists, please pick a different unique identifier.",
|
||||
ResumeNotFound: "It looks like the resume you're looking for doesn't exist.",
|
||||
ResumePrinterError:
|
||||
"Something went wrong while printing your resume. Please try again later or raise an issue on GitHub.",
|
||||
ResumePreviewError:
|
||||
"Something went wrong while grabbing a preview your resume. Please try again later or raise an issue on GitHub.",
|
||||
SomethingWentWrong:
|
||||
"Something went wrong while processing your request. Please try again later or raise an issue on GitHub.",
|
||||
} as const;
|
||||
|
||||
export type ErrorMessage = typeof ErrorMessage;
|
||||
27698
apps/server/src/constants/webfonts.json
Normal file
27698
apps/server/src/constants/webfonts.json
Normal file
File diff suppressed because it is too large
Load Diff
36
apps/server/src/database/database.module.ts
Normal file
36
apps/server/src/database/database.module.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Logger, Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import {
|
||||
loggingMiddleware,
|
||||
PrismaModule,
|
||||
PrismaService,
|
||||
providePrismaClientExceptionFilter,
|
||||
} from "nestjs-prisma";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule.forRootAsync({
|
||||
isGlobal: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService<Config>) => ({
|
||||
prismaOptions: { datasourceUrl: configService.get("DATABASE_URL") },
|
||||
middlewares: [
|
||||
...(configService.get("NODE_ENV") === "development"
|
||||
? [
|
||||
loggingMiddleware({
|
||||
logLevel: "debug",
|
||||
logger: new Logger(PrismaService.name),
|
||||
logMessage: (query) =>
|
||||
`[Query] ${query.model}.${query.action} - ${query.executionTime}ms`,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
],
|
||||
providers: [providePrismaClientExceptionFilter()],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
23
apps/server/src/health/browser.health.ts
Normal file
23
apps/server/src/health/browser.health.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus";
|
||||
import { withTimeout } from "@reactive-resume/utils";
|
||||
|
||||
import { PrinterService } from "../printer/printer.service";
|
||||
|
||||
@Injectable()
|
||||
export class BrowserHealthIndicator extends HealthIndicator {
|
||||
constructor(private readonly printerService: PrinterService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async isHealthy(): Promise<HealthIndicatorResult> {
|
||||
try {
|
||||
const version = await withTimeout(this.printerService.getVersion(), 5000);
|
||||
// const version = await this.printerService.getVersion();
|
||||
|
||||
return this.getStatus("browser", true, { version });
|
||||
} catch (error) {
|
||||
return this.getStatus("browser", false, { message: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
20
apps/server/src/health/database.health.ts
Normal file
20
apps/server/src/health/database.health.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus";
|
||||
import { PrismaService } from "nestjs-prisma";
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseHealthIndicator extends HealthIndicator {
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async isHealthy(): Promise<HealthIndicatorResult> {
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
|
||||
return this.getStatus("database", true);
|
||||
} catch (error) {
|
||||
return this.getStatus("database", false, { message: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
48
apps/server/src/health/health.controller.ts
Normal file
48
apps/server/src/health/health.controller.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { CacheInterceptor, CacheKey, CacheTTL } from "@nestjs/cache-manager";
|
||||
import { Controller, Get, NotFoundException, UseInterceptors } from "@nestjs/common";
|
||||
import { HealthCheck, HealthCheckService } from "@nestjs/terminus";
|
||||
import { RedisService } from "@songkeys/nestjs-redis";
|
||||
import { RedisHealthIndicator } from "@songkeys/nestjs-redis-health";
|
||||
|
||||
import { configSchema } from "../config/schema";
|
||||
import { BrowserHealthIndicator } from "./browser.health";
|
||||
import { DatabaseHealthIndicator } from "./database.health";
|
||||
import { StorageHealthIndicator } from "./storage.health";
|
||||
|
||||
@Controller("health")
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly health: HealthCheckService,
|
||||
private readonly database: DatabaseHealthIndicator,
|
||||
private readonly browser: BrowserHealthIndicator,
|
||||
private readonly storage: StorageHealthIndicator,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly redis: RedisHealthIndicator,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey("health:check")
|
||||
@CacheTTL(30000) // 30 seconds
|
||||
check() {
|
||||
return this.health.check([
|
||||
() => this.database.isHealthy(),
|
||||
() => this.storage.isHealthy(),
|
||||
() => this.browser.isHealthy(),
|
||||
() => {
|
||||
return this.redis.checkHealth("redis", {
|
||||
type: "redis",
|
||||
timeout: 1000,
|
||||
client: this.redisService.getClient(),
|
||||
});
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@Get("environment")
|
||||
environment() {
|
||||
if (process.env.NODE_ENV === "production") throw new NotFoundException();
|
||||
return configSchema.parse(process.env);
|
||||
}
|
||||
}
|
||||
17
apps/server/src/health/health.module.ts
Normal file
17
apps/server/src/health/health.module.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TerminusModule } from "@nestjs/terminus";
|
||||
import { RedisHealthModule } from "@songkeys/nestjs-redis-health";
|
||||
|
||||
import { PrinterModule } from "../printer/printer.module";
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
import { BrowserHealthIndicator } from "./browser.health";
|
||||
import { DatabaseHealthIndicator } from "./database.health";
|
||||
import { HealthController } from "./health.controller";
|
||||
import { StorageHealthIndicator } from "./storage.health";
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule, PrinterModule, StorageModule, RedisHealthModule],
|
||||
controllers: [HealthController],
|
||||
providers: [DatabaseHealthIndicator, BrowserHealthIndicator, StorageHealthIndicator],
|
||||
})
|
||||
export class HealthModule {}
|
||||
21
apps/server/src/health/storage.health.ts
Normal file
21
apps/server/src/health/storage.health.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus";
|
||||
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
|
||||
@Injectable()
|
||||
export class StorageHealthIndicator extends HealthIndicator {
|
||||
constructor(private readonly storageService: StorageService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async isHealthy(): Promise<HealthIndicatorResult> {
|
||||
try {
|
||||
await this.storageService.bucketExists();
|
||||
|
||||
return this.getStatus("storage", true);
|
||||
} catch (error) {
|
||||
return this.getStatus("storage", false, { message: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
36
apps/server/src/mail/mail.module.ts
Normal file
36
apps/server/src/mail/mail.module.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Logger, Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { MailerModule } from "@nestjs-modules/mailer";
|
||||
import * as nodemailer from "nodemailer";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
|
||||
import { MailService } from "./mail.service";
|
||||
|
||||
const emptyTransporter = nodemailer.createTransport({});
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MailerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService<Config>) => {
|
||||
const smtpUrl = configService.get("SMTP_URL");
|
||||
|
||||
if (!smtpUrl) {
|
||||
Logger.warn(
|
||||
"Since `SMTP_URL` is not set, emails would be logged to the console instead. This is not recommended for production environments.",
|
||||
"MailModule",
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
transport: smtpUrl || emptyTransporter,
|
||||
defaults: { from: "Reactive Resume <noreply@rxresu.me>" },
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
providers: [MailService],
|
||||
exports: [MailService],
|
||||
})
|
||||
export class MailModule {}
|
||||
24
apps/server/src/mail/mail.service.ts
Normal file
24
apps/server/src/mail/mail.service.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ISendMailOptions, MailerService } from "@nestjs-modules/mailer";
|
||||
|
||||
import { Config } from "@/server/config/schema";
|
||||
|
||||
@Injectable()
|
||||
export class MailService {
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly mailerService: MailerService,
|
||||
) {}
|
||||
|
||||
async sendEmail(options: ISendMailOptions) {
|
||||
const smtpUrl = this.configService.get("SMTP_URL");
|
||||
|
||||
// If `SMTP_URL` is not set, log the email to the console
|
||||
if (!smtpUrl) {
|
||||
return Logger.log(options, "MailService#sendEmail");
|
||||
}
|
||||
|
||||
return this.mailerService.sendMail(options);
|
||||
}
|
||||
}
|
||||
86
apps/server/src/main.ts
Normal file
86
apps/server/src/main.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
|
||||
import * as Sentry from "@sentry/node";
|
||||
import cookieParser from "cookie-parser";
|
||||
import helmet from "helmet";
|
||||
import { PrismaService } from "nestjs-prisma";
|
||||
import { patchNestJsSwagger } from "nestjs-zod";
|
||||
|
||||
import { AppModule } from "./app.module";
|
||||
import { Config } from "./config/schema";
|
||||
|
||||
patchNestJsSwagger();
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
logger: process.env.NODE_ENV === "development" ? ["debug"] : ["error", "warn", "log"],
|
||||
});
|
||||
const configService = app.get(ConfigService<Config>);
|
||||
const prisma = app.get(PrismaService);
|
||||
|
||||
// Sentry
|
||||
// Error Reporting and Performance Monitoring
|
||||
const sentryDsn = configService.get("SENTRY_DSN");
|
||||
|
||||
if (sentryDsn) {
|
||||
const express = app.getHttpAdapter().getInstance();
|
||||
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
tracesSampleRate: 1.0,
|
||||
profilesSampleRate: 1.0,
|
||||
integrations: [
|
||||
new Sentry.Integrations.Http({ tracing: true }),
|
||||
new Sentry.Integrations.Express({ app: express }),
|
||||
new Sentry.Integrations.Prisma({ client: prisma }),
|
||||
...Sentry.autoDiscoverNodePerformanceMonitoringIntegrations(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Cookie Parser
|
||||
app.use(cookieParser());
|
||||
|
||||
// CORS
|
||||
app.enableCors({
|
||||
credentials: true,
|
||||
origin: process.env.NODE_ENV === "production",
|
||||
});
|
||||
|
||||
// Helmet - enabled only in production
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
}
|
||||
|
||||
// Global Prefix
|
||||
const globalPrefix = "api";
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
|
||||
// Enable Shutdown Hooks
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// Swagger (OpenAPI Docs)
|
||||
// This can be accessed by visiting {SERVER_URL}/api/docs
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle("Reactive Resume")
|
||||
.setDescription(
|
||||
"Reactive Resume is a free and open source resume builder that's built to make the mundane tasks of creating, updating and sharing your resume as easy as 1, 2, 3.",
|
||||
)
|
||||
.addCookieAuth("Authentication", { type: "http", in: "cookie", scheme: "Bearer" })
|
||||
.setVersion("4.0.0")
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup("docs", app, document);
|
||||
|
||||
// Port
|
||||
const port = configService.get<number>("PORT") || 3000;
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
Logger.log(`🚀 Server is up and running on port ${port}`, "Bootstrap");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
12
apps/server/src/printer/printer.module.ts
Normal file
12
apps/server/src/printer/printer.module.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
import { PrinterService } from "./printer.service";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, StorageModule],
|
||||
providers: [PrinterService],
|
||||
exports: [PrinterService],
|
||||
})
|
||||
export class PrinterModule {}
|
||||
308
apps/server/src/printer/printer.service.ts
Normal file
308
apps/server/src/printer/printer.service.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import fontkit from "@pdf-lib/fontkit";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import { getFontUrls, pageSizeMap, withTimeout } from "@reactive-resume/utils";
|
||||
import retry from "async-retry";
|
||||
import { readFile } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { connect } from "puppeteer";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
import { ErrorMessage } from "../constants/error-message";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
|
||||
const MM_TO_PX = 3.78;
|
||||
const PREVIEW_TIMEOUT = 5000; // 5 seconds
|
||||
const PRINTER_TIMEOUT = 15000; // 15 seconds
|
||||
|
||||
@Injectable()
|
||||
export class PrinterService {
|
||||
private readonly logger = new Logger(PrinterService.name);
|
||||
|
||||
private browserEndpoint: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly httpService: HttpService,
|
||||
private readonly utils: UtilsService,
|
||||
) {
|
||||
const chromeUrl = this.configService.getOrThrow<string>("CHROME_URL");
|
||||
const chromeToken = this.configService.getOrThrow<string>("CHROME_TOKEN");
|
||||
|
||||
this.browserEndpoint = `${chromeUrl}?token=${chromeToken}`;
|
||||
}
|
||||
|
||||
private getBrowser() {
|
||||
try {
|
||||
return connect({ browserWSEndpoint: this.browserEndpoint });
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(ErrorMessage.InvalidBrowserConnection, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async getVersion() {
|
||||
const browser = await this.getBrowser();
|
||||
const version = await browser.version();
|
||||
browser.disconnect();
|
||||
return version;
|
||||
}
|
||||
|
||||
async printResume(resume: ResumeDto) {
|
||||
return this.utils.getCachedOrSet(
|
||||
`user:${resume.userId}:storage:resumes:${resume.id}`,
|
||||
async () => {
|
||||
const start = performance.now();
|
||||
|
||||
const url = await retry(() => withTimeout(this.generateResume(resume), PRINTER_TIMEOUT), {
|
||||
retries: 3,
|
||||
randomize: true,
|
||||
onRetry: (_, attempt) => {
|
||||
this.logger.debug(`Retrying resume print job: Attempt #${attempt}`);
|
||||
},
|
||||
});
|
||||
|
||||
const duration = Number(performance.now() - start).toFixed(0);
|
||||
const numPages = resume.data.metadata.layout.length;
|
||||
|
||||
this.logger.debug(`Chrome took ${duration}ms to print ${numPages} page(s)`);
|
||||
|
||||
return url;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async printPreview(resume: ResumeDto) {
|
||||
return this.utils.getCachedOrSet(
|
||||
`user:${resume.userId}:storage:previews:${resume.id}`,
|
||||
async () => {
|
||||
return withTimeout(this.generatePreview(resume), PREVIEW_TIMEOUT);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async generateResume(resume: ResumeDto) {
|
||||
const browser = await this.getBrowser();
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
|
||||
let url = this.utils.getUrl();
|
||||
const publicUrl = this.configService.getOrThrow<string>("PUBLIC_URL");
|
||||
const storageUrl = this.configService.getOrThrow<string>("STORAGE_URL");
|
||||
|
||||
if ([publicUrl, storageUrl].some((url) => url.includes("localhost"))) {
|
||||
// Switch client URL from `localhost` to `host.docker.internal` in development
|
||||
// This is required because the browser is running in a container and the client is running on the host machine.
|
||||
url = url.replace("localhost", "host.docker.internal");
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
|
||||
// Intercept requests of `localhost` to `host.docker.internal` in development
|
||||
page.on("request", (request) => {
|
||||
if (request.url().startsWith(storageUrl)) {
|
||||
const modifiedUrl = request.url().replace("localhost", `host.docker.internal`);
|
||||
|
||||
request.continue({ url: modifiedUrl });
|
||||
} else {
|
||||
request.continue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set the data of the resume to be printed in the browser's session storage
|
||||
const format = resume.data.metadata.page.format;
|
||||
const numPages = resume.data.metadata.layout.length;
|
||||
|
||||
await page.evaluateOnNewDocument((data: string) => {
|
||||
sessionStorage.setItem("resume", data);
|
||||
}, JSON.stringify(resume.data));
|
||||
|
||||
await page.goto(`${url}/printer`, { waitUntil: "networkidle0" });
|
||||
await page.emulateMediaType("print");
|
||||
|
||||
const pagesBuffer: Buffer[] = [];
|
||||
|
||||
// Hide all the pages (elements with [data-page] attribute) using CSS
|
||||
const hidePages = () => {
|
||||
return page.$eval("iframe", (frame) => {
|
||||
frame.contentDocument?.documentElement.querySelectorAll("[data-page]").forEach((page) => {
|
||||
page.setAttribute("style", "display: none");
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const processPage = (index: number) => {
|
||||
// Calculate the height of the page based on the format, convert mm to pixels
|
||||
const pageSize = {
|
||||
width: pageSizeMap[format].width * MM_TO_PX,
|
||||
height: pageSizeMap[format].height * MM_TO_PX,
|
||||
};
|
||||
|
||||
return page.$eval(
|
||||
"iframe",
|
||||
(frame, index, pageSize) => {
|
||||
const page = frame.contentDocument?.querySelector(`[data-page="${index}"]`);
|
||||
page?.setAttribute("style", "display: block");
|
||||
|
||||
return {
|
||||
width: Math.max(pageSize.width, page?.scrollWidth ?? 0),
|
||||
height: Math.max(pageSize.height, page?.scrollHeight ?? 0),
|
||||
};
|
||||
},
|
||||
index,
|
||||
pageSize,
|
||||
);
|
||||
};
|
||||
|
||||
// Loop through all the pages and print them, by first displaying them, printing the PDF and then hiding them back
|
||||
for (let index = 1; index <= numPages; index++) {
|
||||
await hidePages();
|
||||
|
||||
const { width, height } = await processPage(index);
|
||||
const buffer = await page.pdf({ width, height });
|
||||
pagesBuffer.push(buffer);
|
||||
|
||||
await hidePages();
|
||||
}
|
||||
|
||||
// Using 'pdf-lib', merge all the pages from their buffers into a single PDF
|
||||
const pdf = await PDFDocument.create();
|
||||
pdf.registerFontkit(fontkit);
|
||||
|
||||
// Get information about fonts used in the resume from the metadata
|
||||
const fontData = resume.data.metadata.typography.font;
|
||||
|
||||
// Handle Special Case for CMU Serif as it is not available on Google Fonts
|
||||
if (fontData.family === "CMU Serif") {
|
||||
const fontsBuffer = await Promise.all([
|
||||
readFile(join(__dirname, "assets/fonts/computer-modern/regular.ttf")),
|
||||
readFile(join(__dirname, "assets/fonts/computer-modern/italic.ttf")),
|
||||
readFile(join(__dirname, "assets/fonts/computer-modern/bold.ttf")),
|
||||
]);
|
||||
|
||||
await Promise.all(
|
||||
fontsBuffer.map((buffer) => {
|
||||
// Convert Buffer to ArrayBuffer
|
||||
const arrayBuffer = buffer.buffer.slice(
|
||||
buffer.byteOffset,
|
||||
buffer.byteOffset + buffer.byteLength,
|
||||
);
|
||||
return pdf.embedFont(arrayBuffer);
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const fontUrls = getFontUrls(fontData.family, fontData.variants);
|
||||
|
||||
// Load all the fonts from the URLs using HttpService
|
||||
const responses = await Promise.all(
|
||||
fontUrls.map((url) =>
|
||||
this.httpService.axiosRef.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
}),
|
||||
),
|
||||
);
|
||||
const fontsBuffer = responses.map((response) => response.data as ArrayBuffer);
|
||||
|
||||
// Embed all the fonts in the PDF
|
||||
await Promise.all(fontsBuffer.map((buffer) => pdf.embedFont(buffer)));
|
||||
}
|
||||
|
||||
for (let index = 0; index < pagesBuffer.length; index++) {
|
||||
const page = await PDFDocument.load(pagesBuffer[index]);
|
||||
const copiedPage = await pdf.copyPages(page, [0]);
|
||||
pdf.addPage(copiedPage[0]);
|
||||
}
|
||||
|
||||
// Save the PDF to storage and return the URL to download the resume
|
||||
// Store the URL in cache for future requests, under the previously generated hash digest
|
||||
const buffer = Buffer.from(await pdf.save());
|
||||
|
||||
// This step will also save the resume URL in cache
|
||||
const resumeUrl = await this.storageService.uploadObject(
|
||||
resume.userId,
|
||||
"resumes",
|
||||
buffer,
|
||||
resume.id,
|
||||
);
|
||||
|
||||
// Close all the pages and disconnect from the browser
|
||||
await page.close();
|
||||
browser.disconnect();
|
||||
|
||||
return resumeUrl;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(ErrorMessage.ResumePrinterError, error);
|
||||
}
|
||||
}
|
||||
|
||||
async generatePreview(resume: ResumeDto) {
|
||||
const browser = await this.getBrowser();
|
||||
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
|
||||
let url = this.utils.getUrl();
|
||||
const publicUrl = this.configService.getOrThrow<string>("PUBLIC_URL");
|
||||
const storageUrl = this.configService.getOrThrow<string>("STORAGE_URL");
|
||||
|
||||
if ([publicUrl, storageUrl].some((url) => url.includes("localhost"))) {
|
||||
// Switch client URL from `localhost` to `host.docker.internal` in development
|
||||
// This is required because the browser is running in a container and the client is running on the host machine.
|
||||
url = url.replace("localhost", "host.docker.internal");
|
||||
|
||||
await page.setRequestInterception(true);
|
||||
|
||||
// Intercept requests of `localhost` to `host.docker.internal` in development
|
||||
page.on("request", (request) => {
|
||||
if (request.url().startsWith(storageUrl)) {
|
||||
const modifiedUrl = request.url().replace("localhost", `host.docker.internal`);
|
||||
|
||||
request.continue({ url: modifiedUrl });
|
||||
} else {
|
||||
request.continue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set the data of the resume to be printed in the browser's session storage
|
||||
const format = resume.data.metadata.page.format;
|
||||
|
||||
await page.evaluateOnNewDocument((data: string) => {
|
||||
sessionStorage.setItem("resume", data);
|
||||
}, JSON.stringify(resume.data));
|
||||
|
||||
await page.setViewport({
|
||||
width: Math.round(pageSizeMap[format].width * MM_TO_PX),
|
||||
height: Math.round(pageSizeMap[format].height * MM_TO_PX),
|
||||
});
|
||||
|
||||
await page.goto(`${url}/printer`, { waitUntil: "networkidle0" });
|
||||
|
||||
// Save the JPEG to storage and return the URL
|
||||
// Store the URL in cache for future requests, under the previously generated hash digest
|
||||
const buffer = await page.screenshot({ quality: 80, type: "jpeg" });
|
||||
|
||||
// Generate a hash digest of the resume data, this hash will be used to check if the resume has been updated
|
||||
const previewUrl = await this.storageService.uploadObject(
|
||||
resume.userId,
|
||||
"previews",
|
||||
buffer,
|
||||
resume.id,
|
||||
);
|
||||
|
||||
// Close all the pages and disconnect from the browser
|
||||
await page.close();
|
||||
browser.disconnect();
|
||||
|
||||
return previewUrl;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(ErrorMessage.ResumePreviewError, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
apps/server/src/resume/decorators/resume.decorator.ts
Normal file
10
apps/server/src/resume/decorators/resume.decorator.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { createParamDecorator } from "@nestjs/common";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
|
||||
export const Resume = createParamDecorator((data: keyof ResumeDto, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const resume = request.payload?.resume as ResumeDto;
|
||||
|
||||
return data ? resume?.[data] : resume;
|
||||
});
|
||||
43
apps/server/src/resume/guards/resume.guard.ts
Normal file
43
apps/server/src/resume/guards/resume.guard.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { UserWithSecrets } from "@reactive-resume/dto";
|
||||
import { Request } from "express";
|
||||
|
||||
import { ErrorMessage } from "@/server/constants/error-message";
|
||||
|
||||
import { ResumeService } from "../resume.service";
|
||||
|
||||
@Injectable()
|
||||
export class ResumeGuard implements CanActivate {
|
||||
constructor(private readonly resumeService: ResumeService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const user = request.user as UserWithSecrets | false;
|
||||
|
||||
try {
|
||||
const resume = await this.resumeService.findOne(
|
||||
request.params.id,
|
||||
user ? user.id : undefined,
|
||||
);
|
||||
|
||||
// First check if the resume is public, if yes, attach the resume to the request payload.
|
||||
if (resume.visibility === "public") {
|
||||
request.payload = { resume };
|
||||
}
|
||||
|
||||
// If the resume is private and the user is authenticated and is the owner of the resume, attach the resume to the request payload.
|
||||
// Else, if either the user is not authenticated or is not the owner of the resume, throw a 404 error.
|
||||
if (resume.visibility === "private") {
|
||||
if (user && user && user.id === resume.userId) {
|
||||
request.payload = { resume };
|
||||
} else {
|
||||
throw new NotFoundException(ErrorMessage.ResumeNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
throw new NotFoundException(ErrorMessage.ResumeNotFound);
|
||||
}
|
||||
}
|
||||
}
|
||||
144
apps/server/src/resume/resume.controller.ts
Normal file
144
apps/server/src/resume/resume.controller.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
InternalServerErrorException,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import { User as UserEntity } from "@prisma/client";
|
||||
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import {
|
||||
CreateResumeDto,
|
||||
ImportResumeDto,
|
||||
ResumeDto,
|
||||
StatisticsDto,
|
||||
UpdateResumeDto,
|
||||
UrlDto,
|
||||
} from "@reactive-resume/dto";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema";
|
||||
import { ZodSerializerDto } from "nestjs-zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
|
||||
import { User } from "@/server/user/decorators/user.decorator";
|
||||
|
||||
import { OptionalGuard } from "../auth/guards/optional.guard";
|
||||
import { TwoFactorGuard } from "../auth/guards/two-factor.guard";
|
||||
import { ErrorMessage } from "../constants/error-message";
|
||||
import { Resume } from "./decorators/resume.decorator";
|
||||
import { ResumeGuard } from "./guards/resume.guard";
|
||||
import { ResumeService } from "./resume.service";
|
||||
|
||||
@ApiTags("Resume")
|
||||
@Controller("resume")
|
||||
export class ResumeController {
|
||||
constructor(private readonly resumeService: ResumeService) {}
|
||||
|
||||
@Get("schema")
|
||||
@UseInterceptors(CacheInterceptor)
|
||||
@CacheKey("resume:schema")
|
||||
async getSchema() {
|
||||
return zodToJsonSchema(resumeDataSchema);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(TwoFactorGuard)
|
||||
create(@User() user: UserEntity, @Body() createResumeDto: CreateResumeDto) {
|
||||
try {
|
||||
return this.resumeService.create(user.id, createResumeDto);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new BadRequestException(ErrorMessage.ResumeSlugAlreadyExists);
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Post("import")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
import(@User() user: UserEntity, @Body() importResumeDto: ImportResumeDto) {
|
||||
try {
|
||||
return this.resumeService.import(user.id, importResumeDto);
|
||||
} catch (error) {
|
||||
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
throw new BadRequestException(ErrorMessage.ResumeSlugAlreadyExists);
|
||||
}
|
||||
|
||||
throw new InternalServerErrorException(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(TwoFactorGuard)
|
||||
findAll(@User() user: UserEntity) {
|
||||
return this.resumeService.findAll(user.id);
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@UseGuards(TwoFactorGuard, ResumeGuard)
|
||||
findOne(@Resume() resume: ResumeDto) {
|
||||
return resume;
|
||||
}
|
||||
|
||||
@Get(":id/statistics")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(StatisticsDto)
|
||||
findOneStatistics(@User("id") userId: string, @Param("id") id: string) {
|
||||
return this.resumeService.findOneStatistics(userId, id);
|
||||
}
|
||||
|
||||
@Get("/public/:username/:slug")
|
||||
findOneByUsernameSlug(@Param("username") username: string, @Param("slug") slug: string) {
|
||||
return this.resumeService.findOneByUsernameSlug(username, slug);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
update(
|
||||
@User() user: UserEntity,
|
||||
@Param("id") id: string,
|
||||
@Body() updateResumeDto: UpdateResumeDto,
|
||||
) {
|
||||
return this.resumeService.update(user.id, id, updateResumeDto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
remove(@User() user: UserEntity, @Param("id") id: string) {
|
||||
return this.resumeService.remove(user.id, id);
|
||||
}
|
||||
|
||||
@Get("/print/:id")
|
||||
@UseGuards(OptionalGuard, ResumeGuard)
|
||||
@ZodSerializerDto(UrlDto)
|
||||
async printResume(@Resume() resume: ResumeDto) {
|
||||
try {
|
||||
const url = await this.resumeService.printResume(resume);
|
||||
|
||||
return { url };
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(ErrorMessage.ResumePrinterError, error);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/print/:id/preview")
|
||||
@UseGuards(TwoFactorGuard, ResumeGuard)
|
||||
@ZodSerializerDto(UrlDto)
|
||||
async printPreview(@Resume() resume: ResumeDto) {
|
||||
try {
|
||||
const url = await this.resumeService.printPreview(resume);
|
||||
|
||||
return { url };
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(ErrorMessage.ResumePreviewError);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
apps/server/src/resume/resume.module.ts
Normal file
16
apps/server/src/resume/resume.module.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { AuthModule } from "@/server/auth/auth.module";
|
||||
import { PrinterModule } from "@/server/printer/printer.module";
|
||||
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
import { ResumeController } from "./resume.controller";
|
||||
import { ResumeService } from "./resume.service";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrinterModule, StorageModule],
|
||||
controllers: [ResumeController],
|
||||
providers: [ResumeService],
|
||||
exports: [ResumeService],
|
||||
})
|
||||
export class ResumeModule {}
|
||||
176
apps/server/src/resume/resume.service.ts
Normal file
176
apps/server/src/resume/resume.service.ts
Normal file
@ -0,0 +1,176 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { CreateResumeDto, ImportResumeDto, ResumeDto, UpdateResumeDto } from "@reactive-resume/dto";
|
||||
import { defaultResumeData, ResumeData } from "@reactive-resume/schema";
|
||||
import type { DeepPartial } from "@reactive-resume/utils";
|
||||
import { generateRandomName, kebabCase } from "@reactive-resume/utils";
|
||||
import { RedisService } from "@songkeys/nestjs-redis";
|
||||
import { Cache } from "cache-manager";
|
||||
import deepmerge from "deepmerge";
|
||||
import Redis from "ioredis";
|
||||
import { PrismaService } from "nestjs-prisma";
|
||||
|
||||
import { PrinterService } from "@/server/printer/printer.service";
|
||||
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
|
||||
@Injectable()
|
||||
export class ResumeService {
|
||||
private readonly redis: Redis;
|
||||
private readonly logger = new Logger(ResumeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly printerService: PrinterService,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly utils: UtilsService,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
) {
|
||||
this.redis = this.redisService.getClient();
|
||||
}
|
||||
|
||||
async create(userId: string, createResumeDto: CreateResumeDto) {
|
||||
const { name, email, picture } = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id: userId },
|
||||
select: { name: true, email: true, picture: true },
|
||||
});
|
||||
|
||||
const data = deepmerge(defaultResumeData, {
|
||||
basics: { name, email, picture: { url: picture ?? "" } },
|
||||
} satisfies DeepPartial<ResumeData>);
|
||||
|
||||
const resume = await this.prisma.resume.create({
|
||||
data: {
|
||||
data,
|
||||
userId,
|
||||
title: createResumeDto.title,
|
||||
visibility: createResumeDto.visibility,
|
||||
slug: createResumeDto.slug ?? kebabCase(createResumeDto.title),
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.cache.del(`user:${userId}:resumes`),
|
||||
this.cache.set(`user:${userId}:resume:${resume.id}`, resume),
|
||||
]);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async import(userId: string, importResumeDto: ImportResumeDto) {
|
||||
const randomTitle = generateRandomName();
|
||||
|
||||
const resume = await this.prisma.resume.create({
|
||||
data: {
|
||||
userId,
|
||||
visibility: "private",
|
||||
data: importResumeDto.data,
|
||||
title: importResumeDto.title || randomTitle,
|
||||
slug: importResumeDto.slug || kebabCase(randomTitle),
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
this.cache.del(`user:${userId}:resumes`),
|
||||
this.cache.set(`user:${userId}:resume:${resume.id}`, resume),
|
||||
]);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
findAll(userId: string) {
|
||||
return this.utils.getCachedOrSet(`user:${userId}:resumes`, () =>
|
||||
this.prisma.resume.findMany({
|
||||
where: { userId },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
findOne(id: string, userId?: string) {
|
||||
if (userId) {
|
||||
return this.utils.getCachedOrSet(`user:${userId}:resume:${id}`, () =>
|
||||
this.prisma.resume.findUniqueOrThrow({
|
||||
where: { userId_id: { userId, id } },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return this.utils.getCachedOrSet(`user:public:resume:${id}`, () =>
|
||||
this.prisma.resume.findUniqueOrThrow({
|
||||
where: { id },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findOneStatistics(userId: string, id: string) {
|
||||
const result = await Promise.all([
|
||||
this.redis.get(`user:${userId}:resume:${id}:views`),
|
||||
this.redis.get(`user:${userId}:resume:${id}:downloads`),
|
||||
]);
|
||||
|
||||
const [views, downloads] = result.map((value) => Number(value) || 0);
|
||||
|
||||
return { views, downloads };
|
||||
}
|
||||
|
||||
async findOneByUsernameSlug(username: string, slug: string) {
|
||||
const resume = await this.prisma.resume.findFirstOrThrow({
|
||||
where: { user: { username }, slug, visibility: "public" },
|
||||
});
|
||||
|
||||
// Update statistics: increment the number of views by 1
|
||||
await this.redis.incr(`user:${resume.userId}:resume:${resume.id}:views`);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async update(userId: string, id: string, updateResumeDto: UpdateResumeDto) {
|
||||
await Promise.all([
|
||||
this.cache.set(`user:${userId}:resume:${id}`, updateResumeDto),
|
||||
this.cache.del(`user:${userId}:resumes`),
|
||||
this.cache.del(`user:${userId}:storage:resumes:${id}`),
|
||||
this.cache.del(`user:${userId}:storage:previews:${id}`),
|
||||
]);
|
||||
|
||||
return this.prisma.resume.update({
|
||||
data: {
|
||||
title: updateResumeDto.title,
|
||||
slug: updateResumeDto.slug,
|
||||
visibility: updateResumeDto.visibility,
|
||||
data: updateResumeDto.data as unknown as Prisma.JsonObject,
|
||||
},
|
||||
where: { userId_id: { userId, id } },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(userId: string, id: string) {
|
||||
await Promise.all([
|
||||
// Remove cached keys
|
||||
this.cache.del(`user:${userId}:resumes`),
|
||||
this.cache.del(`user:${userId}:resume:${id}`),
|
||||
|
||||
// Remove files in storage, and their cached keys
|
||||
this.storageService.deleteObject(userId, "resumes", id),
|
||||
this.storageService.deleteObject(userId, "previews", id),
|
||||
]);
|
||||
|
||||
return this.prisma.resume.delete({ where: { userId_id: { userId, id } } });
|
||||
}
|
||||
|
||||
async printResume(resume: ResumeDto) {
|
||||
const url = await this.printerService.printResume(resume);
|
||||
|
||||
// Update statistics: increment the number of downloads by 1
|
||||
await this.redis.incr(`user:${resume.userId}:resume:${resume.id}:downloads`);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
printPreview(resume: ResumeDto) {
|
||||
return this.printerService.printPreview(resume);
|
||||
}
|
||||
}
|
||||
32
apps/server/src/storage/storage.controller.ts
Normal file
32
apps/server/src/storage/storage.controller.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Controller,
|
||||
Put,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
|
||||
import { TwoFactorGuard } from "@/server/auth/guards/two-factor.guard";
|
||||
import { User } from "@/server/user/decorators/user.decorator";
|
||||
|
||||
import { StorageService } from "./storage.service";
|
||||
|
||||
@Controller("storage")
|
||||
export class StorageController {
|
||||
constructor(private readonly storageService: StorageService) {}
|
||||
|
||||
@Put("image")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
async uploadFile(@User("id") userId: string, @UploadedFile("file") file: Express.Multer.File) {
|
||||
if (!file.mimetype.startsWith("image")) {
|
||||
throw new BadRequestException(
|
||||
"The file you uploaded doesn't seem to be an image, please upload a file that ends in .jp(e)g or .png.",
|
||||
);
|
||||
}
|
||||
|
||||
return this.storageService.uploadObject(userId, "pictures", file.buffer, userId);
|
||||
}
|
||||
}
|
||||
28
apps/server/src/storage/storage.module.ts
Normal file
28
apps/server/src/storage/storage.module.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type {} from "multer";
|
||||
import { MinioModule } from "nestjs-minio-client";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
import { StorageController } from "./storage.controller";
|
||||
import { StorageService } from "./storage.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
MinioModule.registerAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService<Config>) => ({
|
||||
useSSL: false,
|
||||
endPoint: configService.getOrThrow<string>("STORAGE_ENDPOINT"),
|
||||
port: configService.getOrThrow<number>("STORAGE_PORT"),
|
||||
region: configService.get<string>("STORAGE_REGION"),
|
||||
accessKey: configService.getOrThrow<string>("STORAGE_ACCESS_KEY"),
|
||||
secretKey: configService.getOrThrow<string>("STORAGE_SECRET_KEY"),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [StorageController],
|
||||
providers: [StorageService],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
170
apps/server/src/storage/storage.service.ts
Normal file
170
apps/server/src/storage/storage.service.ts
Normal file
@ -0,0 +1,170 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Cache } from "cache-manager";
|
||||
import { Client } from "minio";
|
||||
import { MinioService } from "nestjs-minio-client";
|
||||
import sharp from "sharp";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
|
||||
// Objects are stored under the following path in the bucket:
|
||||
// "<bucketName>/<userId>/<type>/<fileName>",
|
||||
// where `userId` is a unique identifier (cuid) for the user,
|
||||
// where `type` can either be "pictures", "previews" or "resumes",
|
||||
// and where `fileName` is a unique identifier (cuid) for the file.
|
||||
|
||||
type ImageUploadType = "pictures" | "previews";
|
||||
type DocumentUploadType = "resumes";
|
||||
type UploadType = ImageUploadType | DocumentUploadType;
|
||||
|
||||
const PUBLIC_ACCESS_POLICY = {
|
||||
Version: "2012-10-17",
|
||||
Statement: [
|
||||
{
|
||||
Sid: "PublicAccess",
|
||||
Effect: "Allow",
|
||||
Action: ["s3:GetObject"],
|
||||
Principal: { AWS: ["*"] },
|
||||
Resource: [
|
||||
"arn:aws:s3:::{{bucketName}}/*/pictures/*",
|
||||
"arn:aws:s3:::{{bucketName}}/*/previews/*",
|
||||
"arn:aws:s3:::{{bucketName}}/*/resumes/*",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class StorageService implements OnModuleInit {
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
|
||||
private client: Client;
|
||||
private bucketName: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly minioService: MinioService,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.client = this.minioService.client;
|
||||
this.bucketName = this.configService.getOrThrow<string>("STORAGE_BUCKET");
|
||||
|
||||
try {
|
||||
// Create a storage bucket if it doesn't exist
|
||||
// if it exists, log that we were able to connect to the storage service
|
||||
const bucketExists = await this.client.bucketExists(this.bucketName);
|
||||
|
||||
if (!bucketExists) {
|
||||
const bucketPolicy = JSON.stringify(PUBLIC_ACCESS_POLICY).replace(
|
||||
/{{bucketName}}/g,
|
||||
this.bucketName,
|
||||
);
|
||||
|
||||
await this.client.makeBucket(this.bucketName);
|
||||
await this.client.setBucketPolicy(this.bucketName, bucketPolicy);
|
||||
|
||||
this.logger.log(
|
||||
"A new storage bucket has been created and the policy has been applied successfully.",
|
||||
);
|
||||
} else {
|
||||
this.logger.log("Successfully connected to the storage service.");
|
||||
}
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
"There was an error while creating the storage bucket.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async bucketExists() {
|
||||
const exists = await this.client.bucketExists(this.bucketName);
|
||||
|
||||
if (!exists) {
|
||||
throw new InternalServerErrorException(
|
||||
"There was an error while checking if the storage bucket exists.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadObject(
|
||||
userId: string,
|
||||
type: UploadType,
|
||||
buffer: Buffer,
|
||||
filename: string = createId(),
|
||||
) {
|
||||
const extension = type === "resumes" ? "pdf" : "jpg";
|
||||
const storageUrl = this.configService.get<string>("STORAGE_URL");
|
||||
const filepath = `${userId}/${type}/${filename}.${extension}`;
|
||||
const url = `${storageUrl}/${this.bucketName}/${filepath}`;
|
||||
const metadata =
|
||||
extension === "jpg"
|
||||
? { "Content-Type": "image/jpeg" }
|
||||
: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `attachment; filename=${filename}.${extension}`,
|
||||
};
|
||||
|
||||
try {
|
||||
if (extension === "jpg") {
|
||||
// If the uploaded file is an image, use sharp to resize the image to a maximum width/height of 600px
|
||||
buffer = await sharp(buffer)
|
||||
.resize({ width: 600, height: 600, fit: sharp.fit.outside })
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.client.putObject(this.bucketName, filepath, buffer, metadata),
|
||||
this.cache.set(`user:${userId}:storage:${type}:${filename}`, url),
|
||||
]);
|
||||
|
||||
return url;
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException("There was an error while uploading the file.");
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObject(userId: string, type: UploadType, filename: string) {
|
||||
const extension = type === "resumes" ? "pdf" : "jpg";
|
||||
const path = `${userId}/${type}/${filename}.${extension}`;
|
||||
|
||||
try {
|
||||
return Promise.all([
|
||||
this.cache.del(`user:${userId}:storage:${type}:${filename}`),
|
||||
this.client.removeObject(this.bucketName, path),
|
||||
]);
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
`There was an error while deleting the document at the specified path: ${path}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFolder(prefix: string) {
|
||||
const objectsList = [];
|
||||
|
||||
const objectsStream = this.client.listObjectsV2(this.bucketName, prefix, true);
|
||||
|
||||
for await (const object of objectsStream) {
|
||||
objectsList.push(object.name);
|
||||
}
|
||||
|
||||
try {
|
||||
return this.client.removeObjects(this.bucketName, objectsList);
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(
|
||||
`There was an error while deleting the folder at the specified path: ${this.bucketName}/${prefix}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/server/src/types/express.d.ts
vendored
Normal file
14
apps/server/src/types/express.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
import { Resume, User } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: User;
|
||||
payload?: {
|
||||
resume: Resume;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
11
apps/server/src/user/decorators/user.decorator.ts
Normal file
11
apps/server/src/user/decorators/user.decorator.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { createParamDecorator } from "@nestjs/common";
|
||||
import { UserWithSecrets } from "@reactive-resume/dto";
|
||||
import { Request } from "express";
|
||||
|
||||
export const User = createParamDecorator((data: keyof UserWithSecrets, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest() as Request;
|
||||
const user = request.user as UserWithSecrets;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
});
|
||||
62
apps/server/src/user/user.controller.ts
Normal file
62
apps/server/src/user/user.controller.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Body, Controller, Delete, Get, Patch, Res, UseGuards } from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import { MessageDto, UpdateUserDto, UserDto } from "@reactive-resume/dto";
|
||||
import type { Response } from "express";
|
||||
import { ZodSerializerDto } from "nestjs-zod";
|
||||
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { TwoFactorGuard } from "../auth/guards/two-factor.guard";
|
||||
import { User } from "./decorators/user.decorator";
|
||||
import { UserService } from "./user.service";
|
||||
|
||||
@ApiTags("User")
|
||||
@Controller("user")
|
||||
export class UserController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@Get("me")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(UserDto)
|
||||
fetch(@User() user: UserDto) {
|
||||
return user;
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(UserDto)
|
||||
async update(@User("email") email: string, @Body() updateUserDto: UpdateUserDto) {
|
||||
// If user is updating their email, send a verification email
|
||||
if (updateUserDto.email && updateUserDto.email !== email) {
|
||||
await this.userService.updateByEmail(email, {
|
||||
emailVerified: false,
|
||||
email: updateUserDto.email,
|
||||
});
|
||||
|
||||
await this.authService.sendVerificationEmail(updateUserDto.email);
|
||||
|
||||
email = updateUserDto.email;
|
||||
}
|
||||
|
||||
return this.userService.updateByEmail(email, {
|
||||
name: updateUserDto.name,
|
||||
picture: updateUserDto.picture,
|
||||
username: updateUserDto.username,
|
||||
language: updateUserDto.language,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete("me")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
@ZodSerializerDto(MessageDto)
|
||||
async delete(@User("id") id: string, @Res({ passthrough: true }) response: Response) {
|
||||
await this.userService.deleteOneById(id);
|
||||
|
||||
response.clearCookie("Authentication");
|
||||
response.clearCookie("Refresh");
|
||||
|
||||
response.status(200).send({ message: "Sorry to see you go, goodbye!" });
|
||||
}
|
||||
}
|
||||
14
apps/server/src/user/user.module.ts
Normal file
14
apps/server/src/user/user.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { StorageModule } from "../storage/storage.module";
|
||||
import { UserController } from "./user.controller";
|
||||
import { UserService } from "./user.service";
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => AuthModule.register()), StorageModule],
|
||||
controllers: [UserController],
|
||||
providers: [UserService],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
77
apps/server/src/user/user.service.ts
Normal file
77
apps/server/src/user/user.service.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Inject, Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Cache } from "cache-manager";
|
||||
import { PrismaService } from "nestjs-prisma";
|
||||
|
||||
import { ErrorMessage } from "../constants/error-message";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storageService: StorageService,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
) {}
|
||||
|
||||
async findOneById(id: string) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: { secrets: true },
|
||||
});
|
||||
|
||||
if (!user.secrets) {
|
||||
throw new InternalServerErrorException(ErrorMessage.SecretsNotFound);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async findOneByIdentifier(identifier: string) {
|
||||
const user = await (async (identifier: string) => {
|
||||
// First, find the user by email
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: identifier },
|
||||
include: { secrets: true },
|
||||
});
|
||||
|
||||
// If the user exists, return it
|
||||
if (user) return user;
|
||||
|
||||
// Otherwise, find the user by username
|
||||
// If the user doesn't exist, throw an error
|
||||
return this.prisma.user.findUniqueOrThrow({
|
||||
where: { username: identifier },
|
||||
include: { secrets: true },
|
||||
});
|
||||
})(identifier);
|
||||
|
||||
if (!user.secrets) {
|
||||
throw new InternalServerErrorException(ErrorMessage.SecretsNotFound);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async create(data: Prisma.UserCreateInput) {
|
||||
return this.prisma.user.create({ data, include: { secrets: true } });
|
||||
}
|
||||
|
||||
async updateByEmail(email: string, data: Prisma.UserUpdateArgs["data"]) {
|
||||
return this.prisma.user.update({ where: { email }, data });
|
||||
}
|
||||
|
||||
async updateByResetToken(resetToken: string, data: Prisma.SecretsUpdateArgs["data"]) {
|
||||
await this.prisma.secrets.update({ where: { resetToken }, data });
|
||||
}
|
||||
|
||||
async deleteOneById(id: string) {
|
||||
await Promise.all([
|
||||
...(await this.cache.store.keys(`user:${id}:*`)).map((key) => this.cache.del(key)),
|
||||
this.storageService.deleteFolder(id),
|
||||
]);
|
||||
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
10
apps/server/src/utils/utils.module.ts
Normal file
10
apps/server/src/utils/utils.module.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
|
||||
import { UtilsService } from "./utils.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [UtilsService],
|
||||
exports: [UtilsService],
|
||||
})
|
||||
export class UtilsModule {}
|
||||
56
apps/server/src/utils/utils.service.ts
Normal file
56
apps/server/src/utils/utils.service.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||
import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Cache } from "cache-manager";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
|
||||
@Injectable()
|
||||
export class UtilsService {
|
||||
logger = new Logger(UtilsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
@Inject(CACHE_MANAGER) private readonly cache: Cache,
|
||||
) {}
|
||||
|
||||
getUrl(): string {
|
||||
const url =
|
||||
this.configService.get("NODE_ENV") === "production"
|
||||
? this.configService.get("PUBLIC_URL")
|
||||
: this.configService.get("__DEV__CLIENT_URL");
|
||||
|
||||
if (!url) {
|
||||
throw new InternalServerErrorException("No PUBLIC_URL or __DEV__CLIENT_URL was found.");
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async getCachedOrSet<T>(key: string, callback: () => Promise<T>, ttl?: number): Promise<T> {
|
||||
// Try to get the value from the cache
|
||||
const start = performance.now();
|
||||
const cachedValue = await this.cache.get<T>(key);
|
||||
const duration = Number(performance.now() - start).toFixed(0);
|
||||
|
||||
if (cachedValue === undefined) {
|
||||
this.logger.debug(`Cache Key "${key}": miss`);
|
||||
} else {
|
||||
this.logger.debug(`Cache Key "${key}": hit - ${duration}ms`);
|
||||
}
|
||||
|
||||
// If the value is in the cache, return it
|
||||
if (cachedValue !== undefined) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
// If the value is not in the cache, run the callback
|
||||
const value = await callback();
|
||||
|
||||
// Store the value in the cache
|
||||
await this.cache.set(key, value, ttl);
|
||||
|
||||
// Return the value
|
||||
return value;
|
||||
}
|
||||
}
|
||||
17
apps/server/tsconfig.app.json
Normal file
17
apps/server/tsconfig.app.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["node"],
|
||||
"emitDecoratorMetadata": true,
|
||||
"target": "es2021",
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
16
apps/server/tsconfig.json
Normal file
16
apps/server/tsconfig.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
],
|
||||
"compilerOptions": {
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
9
apps/server/tsconfig.spec.json
Normal file
9
apps/server/tsconfig.spec.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
8
apps/server/webpack.config.js
Normal file
8
apps/server/webpack.config.js
Normal file
@ -0,0 +1,8 @@
|
||||
const { composePlugins, withNx } = require("@nx/webpack");
|
||||
|
||||
// Nx plugins for webpack.
|
||||
module.exports = composePlugins(withNx(), (config) => {
|
||||
// Update the webpack config as needed here.
|
||||
// e.g. `config.plugins.push(new MyPlugin())`
|
||||
return config;
|
||||
});
|
||||
Reference in New Issue
Block a user