mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2025-11-13 08:13:49 +10:00
release: v4.1.0
This commit is contained in:
@ -8,7 +8,9 @@
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
"rules": {
|
||||
"@typescript-eslint/no-extraneous-class": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
/* eslint-disable */
|
||||
export default {
|
||||
displayName: "server",
|
||||
preset: "../../jest.preset.js",
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import path from "node:path";
|
||||
|
||||
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 { 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 { ContributorsModule } from "./contributors/contributors.module";
|
||||
import { DatabaseModule } from "./database/database.module";
|
||||
@ -17,7 +17,6 @@ import { ResumeModule } from "./resume/resume.module";
|
||||
import { StorageModule } from "./storage/storage.module";
|
||||
import { TranslationModule } from "./translation/translation.module";
|
||||
import { UserModule } from "./user/user.module";
|
||||
import { UtilsModule } from "./utils/utils.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -26,8 +25,6 @@ import { UtilsModule } from "./utils/utils.module";
|
||||
DatabaseModule,
|
||||
MailModule,
|
||||
RavenModule,
|
||||
CacheModule,
|
||||
UtilsModule,
|
||||
HealthModule,
|
||||
|
||||
// Feature Modules
|
||||
@ -42,11 +39,13 @@ import { UtilsModule } from "./utils/utils.module";
|
||||
// Static Assets
|
||||
ServeStaticModule.forRoot({
|
||||
serveRoot: "/artboard",
|
||||
rootPath: join(__dirname, "..", "artboard"),
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
rootPath: path.join(__dirname, "..", "artboard"),
|
||||
}),
|
||||
ServeStaticModule.forRoot({
|
||||
renderPath: "/*",
|
||||
rootPath: join(__dirname, "..", "client"),
|
||||
// eslint-disable-next-line unicorn/prefer-module
|
||||
rootPath: path.join(__dirname, "..", "client"),
|
||||
}),
|
||||
],
|
||||
providers: [
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
Res,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
authResponseSchema,
|
||||
@ -29,7 +30,6 @@ import { ErrorMessage } from "@reactive-resume/utils";
|
||||
import type { Response } from "express";
|
||||
|
||||
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";
|
||||
@ -45,7 +45,7 @@ import { payloadSchema } from "./utils/payload";
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly utils: UtilsService,
|
||||
private readonly configService: ConfigService,
|
||||
) {}
|
||||
|
||||
private async exchangeToken(id: string, email: string, isTwoFactorAuth = false) {
|
||||
@ -72,7 +72,8 @@ export class AuthController {
|
||||
) {
|
||||
let status = "authenticated";
|
||||
|
||||
const redirectUrl = new URL(`${this.utils.getUrl()}/auth/callback`);
|
||||
const baseUrl = this.configService.get("PUBLIC_URL");
|
||||
const redirectUrl = new URL(`${baseUrl}/auth/callback`);
|
||||
|
||||
const { accessToken, refreshToken } = await this.exchangeToken(
|
||||
user.id,
|
||||
@ -252,7 +253,7 @@ export class AuthController {
|
||||
async forgotPassword(@Body() { email }: ForgotPasswordDto) {
|
||||
try {
|
||||
await this.authService.forgotPassword(email);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// pass
|
||||
}
|
||||
|
||||
@ -270,7 +271,7 @@ export class AuthController {
|
||||
await this.authService.resetPassword(token, password);
|
||||
|
||||
return { message: "Your password has been successfully reset." };
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new BadRequestException(ErrorMessage.InvalidResetToken);
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,7 +42,7 @@ export class AuthModule {
|
||||
const callbackURL = configService.getOrThrow("GITHUB_CALLBACK_URL");
|
||||
|
||||
return new GitHubStrategy(clientID, clientSecret, callbackURL, userService);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return new DummyStrategy();
|
||||
}
|
||||
},
|
||||
@ -58,7 +58,7 @@ export class AuthModule {
|
||||
const callbackURL = configService.getOrThrow("GOOGLE_CALLBACK_URL");
|
||||
|
||||
return new GoogleStrategy(clientID, clientSecret, callbackURL, userService);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return new DummyStrategy();
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
@ -11,13 +13,11 @@ import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { AuthProvidersDto, LoginDto, RegisterDto, UserWithSecrets } from "@reactive-resume/dto";
|
||||
import { ErrorMessage } from "@reactive-resume/utils";
|
||||
import * as bcryptjs from "bcryptjs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
import { MailService } from "../mail/mail.service";
|
||||
import { UserService } from "../user/user.service";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
import { Payload } from "./utils/payload";
|
||||
|
||||
@Injectable()
|
||||
@ -27,7 +27,6 @@ export class AuthService {
|
||||
private readonly userService: UserService,
|
||||
private readonly mailService: MailService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly utils: UtilsService,
|
||||
) {}
|
||||
|
||||
private hash(password: string): Promise<string> {
|
||||
@ -48,26 +47,26 @@ export class AuthService {
|
||||
|
||||
generateToken(grantType: "access" | "refresh" | "reset" | "verification", payload?: Payload) {
|
||||
switch (grantType) {
|
||||
case "access":
|
||||
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":
|
||||
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":
|
||||
case "verification": {
|
||||
return randomBytes(32).toString("base64url");
|
||||
|
||||
default:
|
||||
throw new InternalServerErrorException("InvalidGrantType: " + grantType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -108,7 +107,7 @@ export class AuthService {
|
||||
});
|
||||
|
||||
// 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);
|
||||
void this.sendVerificationEmail(user.email);
|
||||
|
||||
return user as UserWithSecrets;
|
||||
} catch (error) {
|
||||
@ -123,20 +122,16 @@ export class AuthService {
|
||||
|
||||
async authenticate({ identifier, password }: LoginDto) {
|
||||
try {
|
||||
const user = await this.userService.findOneByIdentifier(identifier);
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException(ErrorMessage.InvalidCredentials);
|
||||
}
|
||||
const user = await this.userService.findOneByIdentifierOrThrow(identifier);
|
||||
|
||||
if (!user.secrets?.password) {
|
||||
throw new BadRequestException(ErrorMessage.OAuthUser);
|
||||
}
|
||||
|
||||
await this.validatePassword(password, user.secrets?.password);
|
||||
await this.validatePassword(password, user.secrets.password);
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new BadRequestException(ErrorMessage.InvalidCredentials);
|
||||
}
|
||||
}
|
||||
@ -149,7 +144,8 @@ export class AuthService {
|
||||
secrets: { update: { resetToken: token } },
|
||||
});
|
||||
|
||||
const url = `${this.utils.getUrl()}/auth/reset-password?token=${token}`;
|
||||
const baseUrl = this.configService.get("PUBLIC_URL");
|
||||
const url = `${baseUrl}/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}`;
|
||||
|
||||
@ -209,7 +205,8 @@ export class AuthService {
|
||||
secrets: { update: { verificationToken: token } },
|
||||
});
|
||||
|
||||
const url = `${this.utils.getUrl()}/auth/verify-email?token=${token}`;
|
||||
const baseUrl = this.configService.get("PUBLIC_URL");
|
||||
const url = `${baseUrl}/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}`;
|
||||
|
||||
@ -238,7 +235,7 @@ export class AuthService {
|
||||
// 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);
|
||||
const user = await this.userService.findOneByIdentifierOrThrow(email);
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new BadRequestException(ErrorMessage.TwoFactorAlreadyEnabled);
|
||||
@ -255,7 +252,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async enable2FA(email: string, code: string) {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
const user = await this.userService.findOneByIdentifierOrThrow(email);
|
||||
|
||||
// If the user already has 2FA enabled, throw an error
|
||||
if (user.twoFactorEnabled) {
|
||||
@ -268,7 +265,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const verified = authenticator.verify({
|
||||
secret: user.secrets?.twoFactorSecret,
|
||||
secret: user.secrets.twoFactorSecret,
|
||||
token: code,
|
||||
});
|
||||
|
||||
@ -288,7 +285,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async disable2FA(email: string) {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
const user = await this.userService.findOneByIdentifierOrThrow(email);
|
||||
|
||||
// If the user doesn't have 2FA enabled, throw an error
|
||||
if (!user.twoFactorEnabled) {
|
||||
@ -302,7 +299,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async verify2FACode(email: string, code: string) {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
const user = await this.userService.findOneByIdentifierOrThrow(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) {
|
||||
@ -310,7 +307,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
const verified = authenticator.verify({
|
||||
secret: user.secrets?.twoFactorSecret,
|
||||
secret: user.secrets.twoFactorSecret,
|
||||
token: code,
|
||||
});
|
||||
|
||||
@ -322,21 +319,21 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async useBackup2FACode(email: string, code: string) {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
const user = await this.userService.findOneByIdentifierOrThrow(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);
|
||||
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);
|
||||
const backupCodes = user.secrets.twoFactorBackupCodes.filter((c) => c !== code);
|
||||
await this.userService.updateByEmail(email, {
|
||||
secrets: { update: { twoFactorBackupCodes: backupCodes } },
|
||||
});
|
||||
|
||||
@ -4,10 +4,6 @@ import { Strategy } from "passport";
|
||||
|
||||
@Injectable()
|
||||
export class DummyStrategy extends PassportStrategy(Strategy, "dummy") {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
authenticate() {
|
||||
this.fail();
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { processUsername } from "@reactive-resume/utils";
|
||||
import { ErrorMessage } from "@reactive-resume/utils";
|
||||
import { ErrorMessage, processUsername } from "@reactive-resume/utils";
|
||||
import { Profile, Strategy, StrategyOptions } from "passport-github2";
|
||||
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
@ -31,17 +30,17 @@ export class GitHubStrategy extends PassportStrategy(Strategy, "github") {
|
||||
|
||||
let user: User | null = null;
|
||||
|
||||
if (!email || !username) throw new BadRequestException();
|
||||
if (!email) throw new BadRequestException();
|
||||
|
||||
try {
|
||||
const user =
|
||||
(await this.userService.findOneByIdentifier(email)) ||
|
||||
(await this.userService.findOneByIdentifier(username));
|
||||
(await this.userService.findOneByIdentifier(email)) ??
|
||||
(username && (await this.userService.findOneByIdentifier(username)));
|
||||
|
||||
if (!user) throw new UnauthorizedException();
|
||||
if (!user) throw new Error("User not found.");
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
try {
|
||||
user = await this.userService.create({
|
||||
email,
|
||||
@ -55,7 +54,7 @@ export class GitHubStrategy extends PassportStrategy(Strategy, "github") {
|
||||
});
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { BadRequestException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { User } from "@prisma/client";
|
||||
import { processUsername } from "@reactive-resume/utils";
|
||||
import { ErrorMessage } from "@reactive-resume/utils";
|
||||
import { ErrorMessage, processUsername } from "@reactive-resume/utils";
|
||||
import { Profile, Strategy, StrategyOptions, VerifyCallback } from "passport-google-oauth20";
|
||||
|
||||
import { UserService } from "@/server/user/user.service";
|
||||
@ -34,12 +33,14 @@ export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
|
||||
if (!email) throw new BadRequestException();
|
||||
|
||||
try {
|
||||
const user = await this.userService.findOneByIdentifier(email);
|
||||
const user =
|
||||
(await this.userService.findOneByIdentifier(email)) ??
|
||||
(username && (await this.userService.findOneByIdentifier(username)));
|
||||
|
||||
if (!user) throw new UnauthorizedException();
|
||||
if (!user) throw new Error("User not found.");
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
try {
|
||||
user = await this.userService.create({
|
||||
email,
|
||||
@ -53,7 +54,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, "google") {
|
||||
});
|
||||
|
||||
done(null, user);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, "jwt") {
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Authentication];
|
||||
const extractors = [(request: Request) => request.cookies.Authentication];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.get<string>("ACCESS_TOKEN_SECRET"),
|
||||
|
||||
@ -14,7 +14,7 @@ export class LocalStrategy extends PassportStrategy(Strategy, "local") {
|
||||
async validate(identifier: string, password: string) {
|
||||
try {
|
||||
return await this.authService.authenticate({ identifier, password });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new BadRequestException(ErrorMessage.InvalidCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ export class RefreshStrategy extends PassportStrategy(Strategy, "refresh") {
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly authService: AuthService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Refresh];
|
||||
const extractors = [(request: Request) => request.cookies.Refresh];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.getOrThrow<string>("REFRESH_TOKEN_SECRET"),
|
||||
@ -26,7 +26,7 @@ export class RefreshStrategy extends PassportStrategy(Strategy, "refresh") {
|
||||
}
|
||||
|
||||
async validate(request: Request, payload: Payload) {
|
||||
const refreshToken = request.cookies?.Refresh;
|
||||
const refreshToken = request.cookies.Refresh;
|
||||
|
||||
return this.authService.validateRefreshToken(payload, refreshToken);
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ export class TwoFactorStrategy extends PassportStrategy(Strategy, "two-factor")
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly userService: UserService,
|
||||
) {
|
||||
const extractors = [(request: Request) => request?.cookies?.Authentication];
|
||||
const extractors = [(request: Request) => request.cookies.Authentication];
|
||||
|
||||
super({
|
||||
secretOrKey: configService.get<string>("ACCESS_TOKEN_SECRET"),
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { InternalServerErrorException } from "@nestjs/common";
|
||||
import { CookieOptions } from "express";
|
||||
|
||||
export const getCookieOptions = (grantType: "access" | "refresh"): CookieOptions => {
|
||||
@ -13,14 +12,10 @@ export const getCookieOptions = (grantType: "access" | "refresh"): CookieOptions
|
||||
}
|
||||
|
||||
// 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);
|
||||
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
|
||||
};
|
||||
};
|
||||
|
||||
17
apps/server/src/cache/cache.module.ts
vendored
17
apps/server/src/cache/cache.module.ts
vendored
@ -1,17 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { RedisModule } from "@songkeys/nestjs-redis";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
RedisModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService<Config>) => ({
|
||||
config: { url: configService.getOrThrow("REDIS_URL") },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class CacheModule {}
|
||||
@ -8,7 +8,7 @@ import { configSchema } from "./schema";
|
||||
NestConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
expandVariables: true,
|
||||
validate: configSchema.parse,
|
||||
validate: (config) => configSchema.parse(config),
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
@ -6,9 +6,6 @@ export const configSchema = z.object({
|
||||
// Ports
|
||||
PORT: z.coerce.number().default(3000),
|
||||
|
||||
// Client URL (only for development environments)
|
||||
__DEV__CLIENT_URL: z.string().url().optional(),
|
||||
|
||||
// URLs
|
||||
PUBLIC_URL: z.string().url(),
|
||||
STORAGE_URL: z.string().url(),
|
||||
@ -43,26 +40,23 @@ export const configSchema = z.object({
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((s) => s !== "false" && s !== "0"),
|
||||
STORAGE_SKIP_CREATE_BUCKET: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((s) => s !== "false" && s !== "0"),
|
||||
|
||||
// Redis
|
||||
REDIS_URL: z.string().url().startsWith("redis://").optional(),
|
||||
|
||||
// Sentry
|
||||
VITE_SENTRY_DSN: z.string().url().startsWith("https://").optional(),
|
||||
SERVER_SENTRY_DSN: z.string().url().startsWith("https://").optional(),
|
||||
|
||||
// Crowdin (Optional)
|
||||
CROWDIN_PROJECT_ID: z.coerce.number().optional(),
|
||||
CROWDIN_PERSONAL_TOKEN: z.string().optional(),
|
||||
|
||||
// Email (Optional)
|
||||
// Flags (Optional)
|
||||
DISABLE_EMAIL_AUTH: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((s) => s !== "false" && s !== "0"),
|
||||
SKIP_STORAGE_BUCKET_CHECK: z
|
||||
.string()
|
||||
.default("false")
|
||||
.transform((s) => s !== "false" && s !== "0"),
|
||||
|
||||
// GitHub (OAuth)
|
||||
GITHUB_CLIENT_ID: z.string().optional(),
|
||||
|
||||
@ -1,30 +1,18 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
import { ContributorsService } from "./contributors.service";
|
||||
|
||||
@Controller("contributors")
|
||||
export class ContributorsController {
|
||||
constructor(
|
||||
private readonly contributorsService: ContributorsService,
|
||||
private readonly utils: UtilsService,
|
||||
) {}
|
||||
constructor(private readonly contributorsService: ContributorsService) {}
|
||||
|
||||
@Get("/github")
|
||||
async githubContributors() {
|
||||
return this.utils.getCachedOrSet(
|
||||
`contributors:github`,
|
||||
async () => this.contributorsService.fetchGitHubContributors(),
|
||||
1000 * 60 * 60 * 24, // 24 hours
|
||||
);
|
||||
return this.contributorsService.fetchGitHubContributors();
|
||||
}
|
||||
|
||||
@Get("/crowdin")
|
||||
async crowdinContributors() {
|
||||
return this.utils.getCachedOrSet(
|
||||
`contributors:crowdin`,
|
||||
async () => this.contributorsService.fetchCrowdinContributors(),
|
||||
1000 * 60 * 60 * 24, // 24 hours
|
||||
);
|
||||
return this.contributorsService.fetchCrowdinContributors();
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ export class ContributorsService {
|
||||
avatar: data.avatarUrl,
|
||||
} satisfies ContributorDto;
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,7 +14,7 @@ import { Config } from "@/server/config/schema";
|
||||
PrismaModule.forRootAsync({
|
||||
isGlobal: true,
|
||||
inject: [ConfigService],
|
||||
useFactory: async (configService: ConfigService<Config>) => ({
|
||||
useFactory: (configService: ConfigService<Config>) => ({
|
||||
prismaOptions: { datasourceUrl: configService.get("DATABASE_URL") },
|
||||
middlewares: [
|
||||
loggingMiddleware({
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import { Controller, Get, NotFoundException } from "@nestjs/common";
|
||||
import { ApiTags } from "@nestjs/swagger";
|
||||
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 { UtilsService } from "../utils/utils.service";
|
||||
import { BrowserHealthIndicator } from "./browser.health";
|
||||
import { DatabaseHealthIndicator } from "./database.health";
|
||||
import { StorageHealthIndicator } from "./storage.health";
|
||||
@ -18,9 +15,6 @@ export class HealthController {
|
||||
private readonly database: DatabaseHealthIndicator,
|
||||
private readonly browser: BrowserHealthIndicator,
|
||||
private readonly storage: StorageHealthIndicator,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly redis: RedisHealthIndicator,
|
||||
private readonly utils: UtilsService,
|
||||
) {}
|
||||
|
||||
private run() {
|
||||
@ -28,20 +22,13 @@ export class HealthController {
|
||||
() => this.database.isHealthy(),
|
||||
() => this.storage.isHealthy(),
|
||||
() => this.browser.isHealthy(),
|
||||
() => {
|
||||
return this.redis.checkHealth("redis", {
|
||||
type: "redis",
|
||||
timeout: 1000,
|
||||
client: this.redisService.getClient(),
|
||||
});
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check() {
|
||||
return this.utils.getCachedOrSet(`health:check`, () => this.run(), 1000 * 30); // 30 seconds
|
||||
return this.run();
|
||||
}
|
||||
|
||||
@Get("environment")
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
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";
|
||||
@ -10,7 +9,7 @@ import { HealthController } from "./health.controller";
|
||||
import { StorageHealthIndicator } from "./storage.health";
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule, PrinterModule, StorageModule, RedisHealthModule],
|
||||
imports: [TerminusModule, PrinterModule, StorageModule],
|
||||
controllers: [HealthController],
|
||||
providers: [DatabaseHealthIndicator, BrowserHealthIndicator, StorageHealthIndicator],
|
||||
})
|
||||
|
||||
@ -14,8 +14,8 @@ export class StorageHealthIndicator extends HealthIndicator {
|
||||
await this.storageService.bucketExists();
|
||||
|
||||
return this.getStatus("storage", true);
|
||||
} catch (error) {
|
||||
return this.getStatus("storage", false, { message: error.message });
|
||||
} catch (error: unknown) {
|
||||
return this.getStatus("storage", false, { message: (error as Error).message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,9 +16,10 @@ export class MailService {
|
||||
|
||||
// If `SMTP_URL` is not set, log the email to the console
|
||||
if (!smtpUrl) {
|
||||
return Logger.log(options, "MailService#sendEmail");
|
||||
Logger.log(options, "MailService#sendEmail");
|
||||
return;
|
||||
}
|
||||
|
||||
return await this.mailerService.sendMail(options);
|
||||
return this.mailerService.sendMail(options);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,15 +23,15 @@ async function bootstrap() {
|
||||
|
||||
// Sentry
|
||||
// Error Reporting and Performance Monitoring
|
||||
const sentryDsn = configService.get("VITE_SENTRY_DSN");
|
||||
const sentryDsn = configService.get("SERVER_SENTRY_DSN");
|
||||
|
||||
if (sentryDsn) {
|
||||
const express = app.getHttpAdapter().getInstance();
|
||||
|
||||
Sentry.init({
|
||||
dsn: sentryDsn,
|
||||
tracesSampleRate: 1.0,
|
||||
profilesSampleRate: 1.0,
|
||||
tracesSampleRate: 1,
|
||||
profilesSampleRate: 1,
|
||||
integrations: [
|
||||
new Sentry.Integrations.Http({ tracing: true }),
|
||||
new Sentry.Integrations.Express({ app: express }),
|
||||
@ -76,11 +76,12 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("docs", app, document);
|
||||
|
||||
// Port
|
||||
const port = configService.get<number>("PORT") || 3000;
|
||||
const port = configService.get<number>("PORT") ?? 3000;
|
||||
|
||||
await app.listen(port);
|
||||
|
||||
Logger.log(`🚀 Server is up and running on port ${port}`, "Bootstrap");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
// eslint-disable-next-line unicorn/prefer-top-level-await
|
||||
void bootstrap();
|
||||
|
||||
@ -1,30 +1,26 @@
|
||||
import { HttpService } from "@nestjs/axios";
|
||||
import { InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import fontkit from "@pdf-lib/fontkit";
|
||||
import { ResumeDto } from "@reactive-resume/dto";
|
||||
import { getFontUrls } from "@reactive-resume/utils";
|
||||
import { ErrorMessage } from "@reactive-resume/utils";
|
||||
import { ErrorMessage, getFontUrls } from "@reactive-resume/utils";
|
||||
import retry from "async-retry";
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
import { connect } from "puppeteer";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
|
||||
@Injectable()
|
||||
export class PrinterService {
|
||||
private readonly logger = new Logger(PrinterService.name);
|
||||
|
||||
private browserURL: string;
|
||||
private readonly browserURL: 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");
|
||||
@ -36,21 +32,24 @@ export class PrinterService {
|
||||
try {
|
||||
return await connect({ browserWSEndpoint: this.browserURL });
|
||||
} catch (error) {
|
||||
throw new InternalServerErrorException(ErrorMessage.InvalidBrowserConnection, error.message);
|
||||
throw new InternalServerErrorException(
|
||||
ErrorMessage.InvalidBrowserConnection,
|
||||
(error as Error).message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getVersion() {
|
||||
const browser = await this.getBrowser();
|
||||
const version = await browser.version();
|
||||
browser.disconnect();
|
||||
await browser.disconnect();
|
||||
return version;
|
||||
}
|
||||
|
||||
async printResume(resume: ResumeDto) {
|
||||
const start = performance.now();
|
||||
|
||||
const url = await retry(() => this.generateResume(resume), {
|
||||
const url = await retry<string | undefined>(() => this.generateResume(resume), {
|
||||
retries: 3,
|
||||
randomize: true,
|
||||
onRetry: (_, attempt) => {
|
||||
@ -59,9 +58,9 @@ export class PrinterService {
|
||||
});
|
||||
|
||||
const duration = Number(performance.now() - start).toFixed(0);
|
||||
const numPages = resume.data.metadata.layout.length;
|
||||
const numberPages = resume.data.metadata.layout.length;
|
||||
|
||||
this.logger.debug(`Chrome took ${duration}ms to print ${numPages} page(s)`);
|
||||
this.logger.debug(`Chrome took ${duration}ms to print ${numberPages} page(s)`);
|
||||
|
||||
return url;
|
||||
}
|
||||
@ -91,10 +90,11 @@ export class PrinterService {
|
||||
const browser = await this.getBrowser();
|
||||
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");
|
||||
|
||||
let url = publicUrl;
|
||||
|
||||
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.
|
||||
@ -107,15 +107,15 @@ export class PrinterService {
|
||||
if (request.url().startsWith(storageUrl)) {
|
||||
const modifiedUrl = request.url().replace("localhost", `host.docker.internal`);
|
||||
|
||||
request.continue({ url: modifiedUrl });
|
||||
void request.continue({ url: modifiedUrl });
|
||||
} else {
|
||||
request.continue();
|
||||
void request.continue();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set the data of the resume to be printed in the browser's session storage
|
||||
const numPages = resume.data.metadata.layout.length;
|
||||
const numberPages = resume.data.metadata.layout.length;
|
||||
|
||||
await page.evaluateOnNewDocument((data) => {
|
||||
window.localStorage.setItem("resume", JSON.stringify(data));
|
||||
@ -127,25 +127,27 @@ export class PrinterService {
|
||||
|
||||
const processPage = async (index: number) => {
|
||||
const pageElement = await page.$(`[data-page="${index}"]`);
|
||||
// eslint-disable-next-line unicorn/no-await-expression-member
|
||||
const width = (await (await pageElement?.getProperty("scrollWidth"))?.jsonValue()) ?? 0;
|
||||
// eslint-disable-next-line unicorn/no-await-expression-member
|
||||
const height = (await (await pageElement?.getProperty("scrollHeight"))?.jsonValue()) ?? 0;
|
||||
|
||||
const tempHtml = await page.evaluate((element: HTMLDivElement) => {
|
||||
const temporaryHtml = await page.evaluate((element: HTMLDivElement) => {
|
||||
const clonedElement = element.cloneNode(true) as HTMLDivElement;
|
||||
const tempHtml = document.body.innerHTML;
|
||||
document.body.innerHTML = `${clonedElement.outerHTML}`;
|
||||
return tempHtml;
|
||||
const temporaryHtml_ = document.body.innerHTML;
|
||||
document.body.innerHTML = clonedElement.outerHTML;
|
||||
return temporaryHtml_;
|
||||
}, pageElement);
|
||||
|
||||
pagesBuffer.push(await page.pdf({ width, height, printBackground: true }));
|
||||
|
||||
await page.evaluate((tempHtml: string) => {
|
||||
document.body.innerHTML = tempHtml;
|
||||
}, tempHtml);
|
||||
await page.evaluate((temporaryHtml_: string) => {
|
||||
document.body.innerHTML = temporaryHtml_;
|
||||
}, temporaryHtml);
|
||||
};
|
||||
|
||||
// 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++) {
|
||||
for (let index = 1; index <= numberPages; index++) {
|
||||
await processPage(index);
|
||||
}
|
||||
|
||||
@ -170,8 +172,8 @@ export class PrinterService {
|
||||
// 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]);
|
||||
for (const element of pagesBuffer) {
|
||||
const page = await PDFDocument.load(element);
|
||||
const [copiedPage] = await pdf.copyPages(page, [0]);
|
||||
pdf.addPage(copiedPage);
|
||||
}
|
||||
@ -190,7 +192,7 @@ export class PrinterService {
|
||||
|
||||
// Close all the pages and disconnect from the browser
|
||||
await page.close();
|
||||
browser.disconnect();
|
||||
await browser.disconnect();
|
||||
|
||||
return resumeUrl;
|
||||
} catch (error) {
|
||||
@ -202,10 +204,11 @@ export class PrinterService {
|
||||
const browser = await this.getBrowser();
|
||||
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");
|
||||
|
||||
let url = publicUrl;
|
||||
|
||||
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.
|
||||
@ -218,9 +221,9 @@ export class PrinterService {
|
||||
if (request.url().startsWith(storageUrl)) {
|
||||
const modifiedUrl = request.url().replace("localhost", `host.docker.internal`);
|
||||
|
||||
request.continue({ url: modifiedUrl });
|
||||
void request.continue({ url: modifiedUrl });
|
||||
} else {
|
||||
request.continue();
|
||||
void request.continue();
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -248,7 +251,7 @@ export class PrinterService {
|
||||
|
||||
// Close all the pages and disconnect from the browser
|
||||
await page.close();
|
||||
browser.disconnect();
|
||||
await browser.disconnect();
|
||||
|
||||
return previewUrl;
|
||||
}
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { createParamDecorator } from "@nestjs/common";
|
||||
import { createParamDecorator, ExecutionContext } 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;
|
||||
export const Resume = createParamDecorator(
|
||||
(data: keyof ResumeDto | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const resume = request.payload?.resume as ResumeDto;
|
||||
|
||||
return data ? resume?.[data] : resume;
|
||||
});
|
||||
return data ? resume[data] : resume;
|
||||
},
|
||||
);
|
||||
|
||||
@ -27,7 +27,7 @@ export class ResumeGuard implements CanActivate {
|
||||
// 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) {
|
||||
if (user && user.id === resume.userId) {
|
||||
request.payload = { resume };
|
||||
} else {
|
||||
throw new NotFoundException(ErrorMessage.ResumeNotFound);
|
||||
@ -35,7 +35,7 @@ export class ResumeGuard implements CanActivate {
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new NotFoundException(ErrorMessage.ResumeNotFound);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,6 @@ import { User } from "@/server/user/decorators/user.decorator";
|
||||
|
||||
import { OptionalGuard } from "../auth/guards/optional.guard";
|
||||
import { TwoFactorGuard } from "../auth/guards/two-factor.guard";
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
import { Resume } from "./decorators/resume.decorator";
|
||||
import { ResumeGuard } from "./guards/resume.guard";
|
||||
import { ResumeService } from "./resume.service";
|
||||
@ -31,18 +30,11 @@ import { ResumeService } from "./resume.service";
|
||||
@ApiTags("Resume")
|
||||
@Controller("resume")
|
||||
export class ResumeController {
|
||||
constructor(
|
||||
private readonly resumeService: ResumeService,
|
||||
private readonly utils: UtilsService,
|
||||
) {}
|
||||
constructor(private readonly resumeService: ResumeService) {}
|
||||
|
||||
@Get("schema")
|
||||
getSchema() {
|
||||
return this.utils.getCachedOrSet(
|
||||
`resume:schema`,
|
||||
() => zodToJsonSchema(resumeDataSchema),
|
||||
1000 * 60 * 60 * 24, // 24 hours
|
||||
);
|
||||
return zodToJsonSchema(resumeDataSchema);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ -89,8 +81,8 @@ export class ResumeController {
|
||||
|
||||
@Get(":id/statistics")
|
||||
@UseGuards(TwoFactorGuard)
|
||||
findOneStatistics(@User("id") userId: string, @Param("id") id: string) {
|
||||
return this.resumeService.findOneStatistics(userId, id);
|
||||
findOneStatistics(@Param("id") id: string) {
|
||||
return this.resumeService.findOneStatistics(id);
|
||||
}
|
||||
|
||||
@Get("/public/:username/:slug")
|
||||
|
||||
@ -8,31 +8,21 @@ 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 { ErrorMessage } from "@reactive-resume/utils";
|
||||
import { RedisService } from "@songkeys/nestjs-redis";
|
||||
import { ErrorMessage, generateRandomName, kebabCase } from "@reactive-resume/utils";
|
||||
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;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly printerService: PrinterService,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly redisService: RedisService,
|
||||
private readonly utils: UtilsService,
|
||||
) {
|
||||
this.redis = this.redisService.getClient();
|
||||
}
|
||||
) {}
|
||||
|
||||
async create(userId: string, createResumeDto: CreateResumeDto) {
|
||||
const { name, email, picture } = await this.prisma.user.findUniqueOrThrow({
|
||||
@ -44,7 +34,7 @@ export class ResumeService {
|
||||
basics: { name, email, picture: { url: picture ?? "" } },
|
||||
} satisfies DeepPartial<ResumeData>);
|
||||
|
||||
const resume = await this.prisma.resume.create({
|
||||
return this.prisma.resume.create({
|
||||
data: {
|
||||
data,
|
||||
userId,
|
||||
@ -53,34 +43,20 @@ export class ResumeService {
|
||||
slug: createResumeDto.slug ?? kebabCase(createResumeDto.title),
|
||||
},
|
||||
});
|
||||
|
||||
// await Promise.all([
|
||||
// this.redis.del(`user:${userId}:resumes`),
|
||||
// this.redis.set(`user:${userId}:resume:${resume.id}`, JSON.stringify(resume)),
|
||||
// ]);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async import(userId: string, importResumeDto: ImportResumeDto) {
|
||||
const randomTitle = generateRandomName();
|
||||
|
||||
const resume = await this.prisma.resume.create({
|
||||
return this.prisma.resume.create({
|
||||
data: {
|
||||
userId,
|
||||
visibility: "private",
|
||||
data: importResumeDto.data,
|
||||
title: importResumeDto.title || randomTitle,
|
||||
slug: importResumeDto.slug || kebabCase(randomTitle),
|
||||
title: importResumeDto.title ?? randomTitle,
|
||||
slug: importResumeDto.slug ?? kebabCase(randomTitle),
|
||||
},
|
||||
});
|
||||
|
||||
// await Promise.all([
|
||||
// this.redis.del(`user:${userId}:resumes`),
|
||||
// this.redis.set(`user:${userId}:resume:${resume.id}`, JSON.stringify(resume)),
|
||||
// ]);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
findAll(userId: string) {
|
||||
@ -95,15 +71,16 @@ export class ResumeService {
|
||||
return 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`),
|
||||
]);
|
||||
async findOneStatistics(id: string) {
|
||||
const result = await this.prisma.statistics.findFirst({
|
||||
select: { views: true, downloads: true },
|
||||
where: { resumeId: id },
|
||||
});
|
||||
|
||||
const [views, downloads] = result.map((value) => Number(value) || 0);
|
||||
|
||||
return { views, downloads };
|
||||
return {
|
||||
views: result?.views ?? 0,
|
||||
downloads: result?.downloads ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async findOneByUsernameSlug(username: string, slug: string, userId?: string) {
|
||||
@ -112,7 +89,13 @@ export class ResumeService {
|
||||
});
|
||||
|
||||
// Update statistics: increment the number of views by 1
|
||||
if (!userId) await this.redis.incr(`user:${resume.userId}:resume:${resume.id}:views`);
|
||||
if (!userId) {
|
||||
await this.prisma.statistics.upsert({
|
||||
where: { resumeId: resume.id },
|
||||
create: { views: 1, downloads: 0, resumeId: resume.id },
|
||||
update: { views: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return resume;
|
||||
}
|
||||
@ -126,7 +109,7 @@ export class ResumeService {
|
||||
|
||||
if (locked) throw new BadRequestException(ErrorMessage.ResumeLocked);
|
||||
|
||||
const resume = await this.prisma.resume.update({
|
||||
return await this.prisma.resume.update({
|
||||
data: {
|
||||
title: updateResumeDto.title,
|
||||
slug: updateResumeDto.slug,
|
||||
@ -135,15 +118,6 @@ export class ResumeService {
|
||||
},
|
||||
where: { userId_id: { userId, id } },
|
||||
});
|
||||
|
||||
// await Promise.all([
|
||||
// this.redis.set(`user:${userId}:resume:${id}`, JSON.stringify(resume)),
|
||||
// this.redis.del(`user:${userId}:resumes`),
|
||||
// this.redis.del(`user:${userId}:storage:resumes:${id}`),
|
||||
// this.redis.del(`user:${userId}:storage:previews:${id}`),
|
||||
// ]);
|
||||
|
||||
return resume;
|
||||
} catch (error) {
|
||||
if (error.code === "P2025") {
|
||||
Logger.error(error);
|
||||
@ -153,25 +127,14 @@ export class ResumeService {
|
||||
}
|
||||
|
||||
async lock(userId: string, id: string, set: boolean) {
|
||||
const resume = await this.prisma.resume.update({
|
||||
return this.prisma.resume.update({
|
||||
data: { locked: set },
|
||||
where: { userId_id: { userId, id } },
|
||||
});
|
||||
|
||||
// await Promise.all([
|
||||
// this.redis.set(`user:${userId}:resume:${id}`, JSON.stringify(resume)),
|
||||
// this.redis.del(`user:${userId}:resumes`),
|
||||
// ]);
|
||||
|
||||
return resume;
|
||||
}
|
||||
|
||||
async remove(userId: string, id: string) {
|
||||
await Promise.all([
|
||||
// Remove cached keys
|
||||
// this.redis.del(`user:${userId}:resumes`),
|
||||
// this.redis.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),
|
||||
@ -184,7 +147,13 @@ export class ResumeService {
|
||||
const url = await this.printerService.printResume(resume);
|
||||
|
||||
// Update statistics: increment the number of downloads by 1
|
||||
if (!userId) await this.redis.incr(`user:${resume.userId}:resume:${resume.id}:downloads`);
|
||||
if (!userId) {
|
||||
await this.prisma.statistics.upsert({
|
||||
where: { resumeId: resume.id },
|
||||
create: { views: 0, downloads: 1, resumeId: resume.id },
|
||||
update: { downloads: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { Injectable, InternalServerErrorException, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { RedisService } from "@songkeys/nestjs-redis";
|
||||
import { Redis } from "ioredis";
|
||||
import { MinioClient, MinioService } from "nestjs-minio-client";
|
||||
import sharp from "sharp";
|
||||
|
||||
@ -33,37 +31,33 @@ const PUBLIC_ACCESS_POLICY = {
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export class StorageService implements OnModuleInit {
|
||||
private readonly redis: Redis;
|
||||
private readonly logger = new Logger(StorageService.name);
|
||||
|
||||
private client: MinioClient;
|
||||
private bucketName: string;
|
||||
|
||||
private skipCreateBucket: boolean;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService<Config>,
|
||||
private readonly minioService: MinioService,
|
||||
private readonly redisService: RedisService,
|
||||
) {
|
||||
this.redis = this.redisService.getClient();
|
||||
}
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.client = this.minioService.client;
|
||||
this.bucketName = this.configService.getOrThrow<string>("STORAGE_BUCKET");
|
||||
this.skipCreateBucket = this.configService.getOrThrow<boolean>("STORAGE_SKIP_CREATE_BUCKET");
|
||||
|
||||
if (this.skipCreateBucket) {
|
||||
this.logger.log("Skipping the creation of the storage bucket.");
|
||||
const skipBucketCheck = this.configService.getOrThrow<boolean>("SKIP_STORAGE_BUCKET_CHECK");
|
||||
|
||||
if (skipBucketCheck) {
|
||||
this.logger.log("Skipping the verification of whether the storage bucket exists.");
|
||||
this.logger.warn("Make sure that the following paths are publicly accessible: ");
|
||||
this.logger.warn("- /pictures/*");
|
||||
this.logger.warn("- /previews/*");
|
||||
this.logger.warn("- /resumes/*");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@ -72,7 +66,9 @@ export class StorageService implements OnModuleInit {
|
||||
// if it exists, log that we were able to connect to the storage service
|
||||
const bucketExists = await this.client.bucketExists(this.bucketName);
|
||||
|
||||
if (!bucketExists) {
|
||||
if (bucketExists) {
|
||||
this.logger.log("Successfully connected to the storage service.");
|
||||
} else {
|
||||
const bucketPolicy = JSON.stringify(PUBLIC_ACCESS_POLICY).replace(
|
||||
/{{bucketName}}/g,
|
||||
this.bucketName,
|
||||
@ -80,7 +76,7 @@ export class StorageService implements OnModuleInit {
|
||||
|
||||
try {
|
||||
await this.client.makeBucket(this.bucketName);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new InternalServerErrorException(
|
||||
"There was an error while creating the storage bucket.",
|
||||
);
|
||||
@ -88,7 +84,7 @@ export class StorageService implements OnModuleInit {
|
||||
|
||||
try {
|
||||
await this.client.setBucketPolicy(this.bucketName, bucketPolicy);
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new InternalServerErrorException(
|
||||
"There was an error while applying the policy to the storage bucket.",
|
||||
);
|
||||
@ -97,8 +93,6 @@ export class StorageService implements OnModuleInit {
|
||||
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(error);
|
||||
@ -122,7 +116,7 @@ export class StorageService implements OnModuleInit {
|
||||
filename: string = createId(),
|
||||
) {
|
||||
const extension = type === "resumes" ? "pdf" : "jpg";
|
||||
const storageUrl = this.configService.get<string>("STORAGE_URL");
|
||||
const storageUrl = this.configService.getOrThrow<string>("STORAGE_URL");
|
||||
const filepath = `${userId}/${type}/${filename}.${extension}`;
|
||||
const url = `${storageUrl}/${filepath}`;
|
||||
const metadata =
|
||||
@ -142,13 +136,10 @@ export class StorageService implements OnModuleInit {
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.client.putObject(this.bucketName, filepath, buffer, metadata),
|
||||
this.redis.set(`user:${userId}:storage:${type}:${filename}`, url),
|
||||
]);
|
||||
await this.client.putObject(this.bucketName, filepath, buffer, metadata);
|
||||
|
||||
return url;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
throw new InternalServerErrorException("There was an error while uploading the file.");
|
||||
}
|
||||
}
|
||||
@ -158,11 +149,9 @@ export class StorageService implements OnModuleInit {
|
||||
const path = `${userId}/${type}/${filename}.${extension}`;
|
||||
|
||||
try {
|
||||
return await Promise.all([
|
||||
this.redis.del(`user:${userId}:storage:${type}:${filename}`),
|
||||
this.client.removeObject(this.bucketName, path),
|
||||
]);
|
||||
} catch (error) {
|
||||
await this.client.removeObject(this.bucketName, path);
|
||||
return;
|
||||
} catch {
|
||||
throw new InternalServerErrorException(
|
||||
`There was an error while deleting the document at the specified path: ${path}.`,
|
||||
);
|
||||
@ -179,8 +168,9 @@ export class StorageService implements OnModuleInit {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.client.removeObjects(this.bucketName, objectsList);
|
||||
} catch (error) {
|
||||
await this.client.removeObjects(this.bucketName, objectsList);
|
||||
return;
|
||||
} catch {
|
||||
throw new InternalServerErrorException(
|
||||
`There was an error while deleting the folder at the specified path: ${this.bucketName}/${prefix}.`,
|
||||
);
|
||||
|
||||
@ -1,21 +1,13 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
|
||||
import { UtilsService } from "../utils/utils.service";
|
||||
import { TranslationService } from "./translation.service";
|
||||
|
||||
@Controller("translation")
|
||||
export class TranslationController {
|
||||
constructor(
|
||||
private readonly translationService: TranslationService,
|
||||
private readonly utils: UtilsService,
|
||||
) {}
|
||||
constructor(private readonly translationService: TranslationService) {}
|
||||
|
||||
@Get("/languages")
|
||||
async languages() {
|
||||
return this.utils.getCachedOrSet(
|
||||
`translation:languages`,
|
||||
async () => this.translationService.fetchLanguages(),
|
||||
1000 * 60 * 60 * 24, // 24 hours
|
||||
);
|
||||
return this.translationService.fetchLanguages();
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,7 +56,7 @@ export class TranslationService {
|
||||
locale: data.language.locale,
|
||||
} satisfies Language;
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return languages;
|
||||
}
|
||||
}
|
||||
|
||||
5
apps/server/src/types/express.d.ts
vendored
5
apps/server/src/types/express.d.ts
vendored
@ -1,9 +1,10 @@
|
||||
import { Resume, User } from "@prisma/client";
|
||||
import { Resume, User as PrismaUser } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface Request {
|
||||
user?: User;
|
||||
user?: PrismaUser;
|
||||
payload?: {
|
||||
resume: Resume;
|
||||
};
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { createParamDecorator } from "@nestjs/common";
|
||||
import { createParamDecorator, ExecutionContext } 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;
|
||||
export const User = createParamDecorator(
|
||||
(data: keyof UserWithSecrets | undefined, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
const user = request.user as UserWithSecrets;
|
||||
|
||||
return data ? user?.[data] : user;
|
||||
});
|
||||
return data ? user[data] : user;
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,23 +1,16 @@
|
||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { ErrorMessage } from "@reactive-resume/utils";
|
||||
import { RedisService } from "@songkeys/nestjs-redis";
|
||||
import Redis from "ioredis";
|
||||
import { PrismaService } from "nestjs-prisma";
|
||||
|
||||
import { StorageService } from "../storage/storage.service";
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storageService: StorageService,
|
||||
private readonly redisService: RedisService,
|
||||
) {
|
||||
this.redis = this.redisService.getClient();
|
||||
}
|
||||
) {}
|
||||
|
||||
async findOneById(id: string) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({
|
||||
@ -45,25 +38,43 @@ export class UserService {
|
||||
|
||||
// Otherwise, find the user by username
|
||||
// If the user doesn't exist, throw an error
|
||||
return await this.prisma.user.findUniqueOrThrow({
|
||||
return this.prisma.user.findUnique({
|
||||
where: { username: identifier },
|
||||
include: { secrets: true },
|
||||
});
|
||||
})(identifier);
|
||||
|
||||
if (!user.secrets) {
|
||||
throw new InternalServerErrorException(ErrorMessage.SecretsNotFound);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async findOneByIdentifierOrThrow(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);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async create(data: Prisma.UserCreateInput) {
|
||||
return await this.prisma.user.create({ data, include: { secrets: true } });
|
||||
return this.prisma.user.create({ data, include: { secrets: true } });
|
||||
}
|
||||
|
||||
async updateByEmail(email: string, data: Prisma.UserUpdateArgs["data"]) {
|
||||
return await this.prisma.user.update({ where: { email }, data });
|
||||
return this.prisma.user.update({ where: { email }, data });
|
||||
}
|
||||
|
||||
async updateByResetToken(resetToken: string, data: Prisma.SecretsUpdateArgs["data"]) {
|
||||
@ -71,8 +82,8 @@ export class UserService {
|
||||
}
|
||||
|
||||
async deleteOneById(id: string) {
|
||||
await Promise.all([this.redis.del(`user:${id}:*`), this.storageService.deleteFolder(id)]);
|
||||
await this.storageService.deleteFolder(id);
|
||||
|
||||
return await this.prisma.user.delete({ where: { id } });
|
||||
return this.prisma.user.delete({ where: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
import { Global, Module } from "@nestjs/common";
|
||||
|
||||
import { UtilsService } from "./utils.service";
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [UtilsService],
|
||||
exports: [UtilsService],
|
||||
})
|
||||
export class UtilsModule {}
|
||||
@ -1,65 +0,0 @@
|
||||
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { RedisService } from "@songkeys/nestjs-redis";
|
||||
import Redis from "ioredis";
|
||||
|
||||
import { Config } from "../config/schema";
|
||||
|
||||
@Injectable()
|
||||
export class UtilsService {
|
||||
private readonly redis: Redis;
|
||||
logger = new Logger(UtilsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly redisService: RedisService,
|
||||
private readonly configService: ConfigService<Config>,
|
||||
) {
|
||||
this.redis = this.redisService.getClient();
|
||||
}
|
||||
|
||||
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> | T,
|
||||
ttl: number = 1000 * 60 * 60 * 24, // 24 hours
|
||||
type: "json" | "string" = "json",
|
||||
): Promise<T> {
|
||||
// Try to get the value from the cache
|
||||
const start = performance.now();
|
||||
const cachedValue = await this.redis.get(key);
|
||||
const duration = Number(performance.now() - start).toFixed(0);
|
||||
|
||||
if (!cachedValue) {
|
||||
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) {
|
||||
return (type === "string" ? cachedValue : JSON.parse(cachedValue)) as T;
|
||||
}
|
||||
|
||||
// If the value is not in the cache, run the callback
|
||||
const value = await callback();
|
||||
const valueToCache = (type === "string" ? value : JSON.stringify(value)) as string;
|
||||
|
||||
// Store the value in the cache
|
||||
await this.redis.set(key, valueToCache, "PX", ttl);
|
||||
|
||||
// Return the value
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,6 @@
|
||||
"types": ["node"],
|
||||
"emitDecoratorMetadata": true,
|
||||
"target": "es2021",
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
|
||||
Reference in New Issue
Block a user