initial commit of v5

This commit is contained in:
Amruth Pillai
2026-01-19 23:31:54 +01:00
parent 55bdfd0067
commit cad390fa13
1132 changed files with 200807 additions and 165288 deletions
-72
View File
@@ -1,72 +0,0 @@
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 { AuthModule } from "./auth/auth.module";
import { ConfigModule } from "./config/config.module";
import { ContributorsModule } from "./contributors/contributors.module";
import { DatabaseModule } from "./database/database.module";
import { FeatureModule } from "./feature/feature.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 { TranslationModule } from "./translation/translation.module";
import { UserModule } from "./user/user.module";
@Module({
imports: [
// Core Modules
ConfigModule,
DatabaseModule,
MailModule,
RavenModule,
HealthModule,
// Feature Modules
AuthModule.register(),
UserModule,
ResumeModule,
StorageModule,
PrinterModule,
FeatureModule,
TranslationModule,
ContributorsModule,
// Static Assets
ServeStaticModule.forRoot({
serveRoot: "/artboard",
// eslint-disable-next-line unicorn/prefer-module
rootPath: path.join(__dirname, "..", "artboard"),
}),
ServeStaticModule.forRoot({
renderPath: "/*",
// eslint-disable-next-line unicorn/prefer-module
rootPath: path.join(__dirname, "..", "client"),
}),
],
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{
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 {}
View File
-337
View File
@@ -1,337 +0,0 @@
import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
InternalServerErrorException,
Patch,
Post,
Query,
Res,
UseGuards,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags } from "@nestjs/swagger";
import {
authResponseSchema,
backupCodesSchema,
ForgotPasswordDto,
messageSchema,
RegisterDto,
ResetPasswordDto,
TwoFactorBackupDto,
TwoFactorDto,
UpdatePasswordDto,
userSchema,
UserWithSecrets,
} from "@reactive-resume/dto";
import { ErrorMessage } from "@reactive-resume/utils";
import type { Response } from "express";
import { User } from "../user/decorators/user.decorator";
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 { OpenIDGuard } from "./guards/openid.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 configService: ConfigService,
) {}
private async exchangeToken(id: string, email: string, isTwoFactorAuth = 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 = false,
redirect = false,
) {
let status = "authenticated";
const baseUrl = this.configService.get("PUBLIC_URL");
const redirectUrl = new URL(`${baseUrl}/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);
}
@Get("providers")
getAuthProviders() {
return this.authService.getAuthProviders();
}
// OAuth Flows
@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);
}
@ApiTags("OAuth", "OpenID")
@Get("openid")
@UseGuards(OpenIDGuard)
openidLogin() {
return;
}
@ApiTags("OAuth", "OpenID")
@Get("openid/callback")
@UseGuards(OpenIDGuard)
async openidCallback(
@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)
async updatePassword(
@User("email") email: string,
@Body() { currentPassword, newPassword }: UpdatePasswordDto,
) {
await this.authService.updatePassword(email, currentPassword, newPassword);
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)
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)
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)
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)
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 {
// 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")
async resetPassword(@Body() { token, password }: ResetPasswordDto) {
try {
await this.authService.resetPassword(token, password);
return { message: "Your password has been successfully reset." };
} catch {
throw new BadRequestException(ErrorMessage.InvalidResetToken);
}
}
// Email Verification Flows
@ApiTags("Email Verification")
@Post("verify-email")
@UseGuards(TwoFactorGuard)
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)
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.",
};
}
}
-102
View File
@@ -1,102 +0,0 @@
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 { OpenIDStrategy } from "./strategy/openid.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: 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 {
return new DummyStrategy();
}
},
},
{
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 {
return new DummyStrategy();
}
},
},
{
provide: OpenIDStrategy,
inject: [ConfigService, UserService],
useFactory: (configService: ConfigService<Config>, userService: UserService) => {
try {
const authorizationURL = configService.getOrThrow("OPENID_AUTHORIZATION_URL");
const callbackURL = configService.getOrThrow("OPENID_CALLBACK_URL");
const clientID = configService.getOrThrow("OPENID_CLIENT_ID");
const clientSecret = configService.getOrThrow("OPENID_CLIENT_SECRET");
const issuer = configService.getOrThrow("OPENID_ISSUER");
const scope = configService.getOrThrow("OPENID_SCOPE");
const tokenURL = configService.getOrThrow("OPENID_TOKEN_URL");
const userInfoURL = configService.getOrThrow("OPENID_USER_INFO_URL");
return new OpenIDStrategy(
authorizationURL,
callbackURL,
clientID,
clientSecret,
issuer,
scope,
tokenURL,
userInfoURL,
userService,
);
} catch {
return new DummyStrategy();
}
},
},
],
exports: [AuthService],
};
}
}
-371
View File
@@ -1,371 +0,0 @@
import { randomBytes } from "node:crypto";
import {
BadRequestException,
ForbiddenException,
Injectable,
InternalServerErrorException,
Logger,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
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 { authenticator } from "otplib";
import { Config } from "../config/schema";
import { MailService } from "../mail/mail.service";
import { UserService } from "../user/user.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 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");
}
}
}
async setLastSignedIn(email: string) {
await this.userService.updateByEmail(email, {
secrets: { update: { lastSignedIn: new Date() } },
});
}
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): Promise<UserWithSecrets> {
const hashedPassword = await this.hash(registerDto.password);
try {
const user = await this.userService.create({
name: registerDto.name,
email: registerDto.email,
username: registerDto.username,
locale: registerDto.locale,
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
void this.sendVerificationEmail(user.email);
return user;
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
}
Logger.error(error);
throw new InternalServerErrorException(error);
}
}
async authenticate({ identifier, password }: LoginDto) {
try {
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.setLastSignedIn(user.email);
return user;
} catch {
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 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}`;
await this.mailService.sendEmail({ to: email, subject, text });
}
async updatePassword(email: string, currentPassword: string, newPassword: string) {
const user = await this.userService.findOneByIdentifierOrThrow(email);
if (!user.secrets?.password) {
throw new BadRequestException(ErrorMessage.OAuthUser);
}
await this.validatePassword(currentPassword, user.secrets.password);
const newHashedPassword = await this.hash(newPassword);
await this.userService.updateByEmail(email, {
secrets: { update: { password: newHashedPassword } },
});
}
async resetPassword(token: string, password: string) {
const hashedPassword = await this.hash(password);
await this.userService.updateByResetToken(token, {
resetToken: null,
password: hashedPassword,
});
}
getAuthProviders() {
const providers: AuthProvidersDto = [];
if (!this.configService.get("DISABLE_EMAIL_AUTH")) {
providers.push("email");
}
if (
this.configService.get("GITHUB_CLIENT_ID") &&
this.configService.get("GITHUB_CLIENT_SECRET") &&
this.configService.get("GITHUB_CALLBACK_URL")
) {
providers.push("github");
}
if (
this.configService.get("GOOGLE_CLIENT_ID") &&
this.configService.get("GOOGLE_CLIENT_SECRET") &&
this.configService.get("GOOGLE_CALLBACK_URL")
) {
providers.push("google");
}
if (
this.configService.get("OPENID_AUTHORIZATION_URL") &&
this.configService.get("OPENID_CALLBACK_URL") &&
this.configService.get("OPENID_CLIENT_ID") &&
this.configService.get("OPENID_CLIENT_SECRET") &&
this.configService.get("OPENID_ISSUER") &&
this.configService.get("OPENID_SCOPE") &&
this.configService.get("OPENID_TOKEN_URL") &&
this.configService.get("OPENID_USER_INFO_URL")
) {
providers.push("openid");
}
return providers;
}
// 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 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}`;
await this.mailService.sendEmail({ to: email, subject, text });
} catch (error) {
Logger.error(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.findOneByIdentifierOrThrow(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.findOneByIdentifierOrThrow(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.findOneByIdentifierOrThrow(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.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 = authenticator.verify({
secret: user.secrets.twoFactorSecret,
token: code,
});
if (!verified) {
throw new BadRequestException(ErrorMessage.InvalidTwoFactorCode);
}
return user;
}
async useBackup2FACode(email: string, code: string): Promise<UserWithSecrets> {
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);
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;
}
}
@@ -1,5 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class GitHubGuard extends AuthGuard("github") {}
@@ -1,5 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class GoogleGuard extends AuthGuard("google") {}
-5
View File
@@ -1,5 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtGuard extends AuthGuard("jwt") {}
@@ -1,5 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalGuard extends AuthGuard("local") {}
@@ -1,5 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class OpenIDGuard extends AuthGuard("openid") {}
@@ -1,10 +0,0 @@
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;
}
}
@@ -1,5 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class RefreshGuard extends AuthGuard("refresh") {}
@@ -1,5 +0,0 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class TwoFactorGuard extends AuthGuard("two-factor") {}
@@ -1,10 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport";
@Injectable()
export class DummyStrategy extends PassportStrategy(Strategy, "dummy") {
authenticate() {
this.fail();
}
}
@@ -1,65 +0,0 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { createId } from "@paralleldrive/cuid2";
import { User } from "@prisma/client";
import { ErrorMessage, processUsername } from "@reactive-resume/utils";
import { Profile, Strategy, StrategyOptions } from "passport-github2";
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`).toLocaleLowerCase();
const picture = photos?.[0].value;
let user: User | null = null;
if (!email) throw new BadRequestException(ErrorMessage.InvalidCredentials);
try {
user =
(await this.userService.findOneByIdentifier(email)) ??
(username ? await this.userService.findOneByIdentifier(username) : null);
if (!user) throw new BadRequestException(ErrorMessage.InvalidCredentials);
done(null, user);
} catch {
try {
user = await this.userService.create({
email,
picture,
locale: "en-US",
provider: "github",
name: displayName || createId(),
emailVerified: true, // auto-verify emails
username: processUsername(username ?? email.split("@")[0]),
secrets: { create: {} },
});
done(null, user);
} catch (error) {
Logger.error(error);
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
}
}
}
}
@@ -1,65 +0,0 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { createId } from "@paralleldrive/cuid2";
import { User } from "@prisma/client";
import { ErrorMessage, processUsername } from "@reactive-resume/utils";
import { Profile, Strategy, StrategyOptions, VerifyCallback } from "passport-google-oauth20";
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`).toLocaleLowerCase();
const picture = photos?.[0].value;
let user: User | null = null;
if (!email) throw new BadRequestException(ErrorMessage.InvalidCredentials);
try {
user =
(await this.userService.findOneByIdentifier(email)) ??
(username ? await this.userService.findOneByIdentifier(username) : null);
if (!user) throw new BadRequestException(ErrorMessage.InvalidCredentials);
done(null, user);
} catch {
try {
user = await this.userService.create({
email,
picture,
locale: "en-US",
provider: "google",
name: displayName || createId(),
emailVerified: true, // auto-verify emails
username: processUsername(username ?? email.split("@")[0]),
secrets: { create: {} },
});
done(null, user);
} catch (error) {
Logger.error(error);
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
}
}
}
}
@@ -1,30 +0,0 @@
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);
}
}
@@ -1,21 +0,0 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ErrorMessage } from "@reactive-resume/utils";
import { IStrategyOptions, Strategy } from "passport-local";
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 await this.authService.authenticate({ identifier, password });
} catch {
throw new BadRequestException(ErrorMessage.InvalidCredentials);
}
}
}
@@ -1,78 +0,0 @@
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { User } from "@prisma/client";
import { ErrorMessage, generateRandomName, processUsername } from "@reactive-resume/utils";
import { Profile, Strategy, StrategyOptions } from "passport-openidconnect";
import { UserService } from "@/server/user/user.service";
@Injectable()
export class OpenIDStrategy extends PassportStrategy(Strategy, "openid") {
constructor(
readonly authorizationURL: string,
readonly callbackURL: string,
readonly clientID: string,
readonly clientSecret: string,
readonly issuer: string,
readonly scope: string,
readonly tokenURL: string,
readonly userInfoURL: string,
private readonly userService: UserService,
) {
super({
authorizationURL,
callbackURL,
clientID,
clientSecret,
issuer,
scope,
tokenURL,
userInfoURL,
} as StrategyOptions);
}
async validate(
_issuer: unknown,
profile: Profile,
done: (err?: string | Error | null, user?: Express.User, info?: unknown) => void,
) {
const { displayName, emails, photos, username } = profile;
const uniqueId = generateRandomName({ length: 2, style: "lowerCase", separator: "-" });
const email = (emails?.[0].value ?? `${username ?? uniqueId}@openid.com`).toLocaleLowerCase();
const picture = photos?.[0].value;
let user: User | null = null;
if (!email) throw new BadRequestException(ErrorMessage.InvalidCredentials);
try {
user =
(await this.userService.findOneByIdentifier(email)) ??
(username ? await this.userService.findOneByIdentifier(username) : null);
if (!user) throw new BadRequestException(ErrorMessage.InvalidCredentials);
done(null, user);
} catch {
try {
user = await this.userService.create({
email,
picture,
locale: "en-US",
provider: "openid",
name: displayName || uniqueId,
emailVerified: true, // auto-verify emails
username: processUsername(username ?? email.split("@")[0]),
secrets: { create: {} },
});
done(null, user);
} catch (error) {
Logger.error(error);
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
}
}
}
}
@@ -1,33 +0,0 @@
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);
}
}
@@ -1,35 +0,0 @@
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;
}
}
-21
View File
@@ -1,21 +0,0 @@
import type { 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
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
};
};
-9
View File
@@ -1,9 +0,0 @@
import { idSchema } from "@reactive-resume/schema";
import { z } from "zod";
export const payloadSchema = z.object({
id: idSchema,
isTwoFactorAuth: z.boolean().optional(),
});
export type Payload = z.infer<typeof payloadSchema>;
-15
View File
@@ -1,15 +0,0 @@
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: (config) => configSchema.parse(config),
}),
],
})
export class ConfigModule {}
-88
View File
@@ -1,88 +0,0 @@
import { z } from "zod";
export const configSchema = z.object({
NODE_ENV: z.enum(["development", "production"]).default("production"),
// Ports
PORT: z.coerce.number().default(3000),
// URLs
PUBLIC_URL: z.string().url(),
STORAGE_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(),
CHROME_URL: z.string().url(),
CHROME_IGNORE_HTTPS_ERRORS: z
.string()
.default("false")
.transform((s) => s !== "false" && s !== "0"),
// Mail Server
MAIL_FROM: z.string().includes("@").optional().default("noreply@localhost"),
SMTP_URL: z
.string()
.url()
.refine((url) => url.startsWith("smtp://") || url.startsWith("smtps://"))
.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(),
STORAGE_USE_SSL: z
.string()
.default("false")
.transform((s) => s !== "false" && s !== "0"),
STORAGE_SKIP_BUCKET_CHECK: z
.string()
.default("false")
.transform((s) => s !== "false" && s !== "0"),
// Crowdin (Optional)
CROWDIN_PROJECT_ID: z.coerce.number().optional(),
CROWDIN_PERSONAL_TOKEN: z.string().optional(),
// Feature Flags (Optional)
DISABLE_SIGNUPS: z
.string()
.default("false")
.transform((s) => s !== "false" && s !== "0"),
DISABLE_EMAIL_AUTH: z
.string()
.default("false")
.transform((s) => s !== "false" && s !== "0"),
// GitHub (OAuth, Optional)
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GITHUB_CALLBACK_URL: z.string().url().optional(),
// Google (OAuth, Optional)
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GOOGLE_CALLBACK_URL: z.string().url().optional(),
// OpenID (Optional)
VITE_OPENID_NAME: z.string().optional(),
OPENID_AUTHORIZATION_URL: z.string().url().optional(),
OPENID_CALLBACK_URL: z.string().url().optional(),
OPENID_CLIENT_ID: z.string().optional(),
OPENID_CLIENT_SECRET: z.string().optional(),
OPENID_ISSUER: z.string().optional(),
OPENID_SCOPE: z.string().optional(),
OPENID_TOKEN_URL: z.string().url().optional(),
OPENID_USER_INFO_URL: z.string().url().optional(),
});
export type Config = z.infer<typeof configSchema>;
@@ -1,18 +0,0 @@
import { Controller, Get } from "@nestjs/common";
import { ContributorsService } from "./contributors.service";
@Controller("contributors")
export class ContributorsController {
constructor(private readonly contributorsService: ContributorsService) {}
@Get("/github")
async githubContributors() {
return this.contributorsService.fetchGitHubContributors();
}
@Get("/crowdin")
async crowdinContributors() {
return this.contributorsService.fetchCrowdinContributors();
}
}
@@ -1,12 +0,0 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";
import { ContributorsController } from "./contributors.controller";
import { ContributorsService } from "./contributors.service";
@Module({
imports: [HttpModule],
controllers: [ContributorsController],
providers: [ContributorsService],
})
export class ContributorsModule {}
@@ -1,64 +0,0 @@
import { HttpService } from "@nestjs/axios";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ContributorDto } from "@reactive-resume/dto";
import { Config } from "../config/schema";
type GitHubResponse = { id: number; login: string; html_url: string; avatar_url: string }[];
type CrowdinContributorsResponse = {
data: { data: { id: number; username: string; avatarUrl: string } }[];
};
@Injectable()
export class ContributorsService {
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService<Config>,
) {}
async fetchGitHubContributors() {
const response = await this.httpService.axiosRef.get(
`https://api.github.com/repos/AmruthPillai/Reactive-Resume/contributors`,
);
const data = response.data as GitHubResponse;
return data
.filter((_, index) => index <= 20)
.map((user) => {
return {
id: user.id,
name: user.login,
url: user.html_url,
avatar: user.avatar_url,
} satisfies ContributorDto;
});
}
async fetchCrowdinContributors() {
try {
const projectId = this.configService.getOrThrow("CROWDIN_PROJECT_ID");
const accessToken = this.configService.getOrThrow("CROWDIN_PERSONAL_TOKEN");
const response = await this.httpService.axiosRef.get(
`https://api.crowdin.com/api/v2/projects/${projectId}/members`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
const { data } = response.data as CrowdinContributorsResponse;
return data
.filter((_, index) => index <= 20)
.map(({ data }) => {
return {
id: data.id,
name: data.username,
url: `https://crowdin.com/profile/${data.username}`,
avatar: data.avatarUrl,
} satisfies ContributorDto;
});
} catch {
return [];
}
}
}
@@ -1,19 +0,0 @@
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PrismaModule, providePrismaClientExceptionFilter } from "nestjs-prisma";
import { Config } from "@/server/config/schema";
@Module({
imports: [
PrismaModule.forRootAsync({
isGlobal: true,
inject: [ConfigService],
useFactory: (configService: ConfigService<Config>) => ({
prismaOptions: { datasourceUrl: configService.get("DATABASE_URL") },
}),
}),
],
providers: [providePrismaClientExceptionFilter()],
})
export class DatabaseModule {}
@@ -1,13 +0,0 @@
import { Controller, Get } from "@nestjs/common";
import { FeatureService } from "./feature.service";
@Controller("feature")
export class FeatureController {
constructor(private readonly featureService: FeatureService) {}
@Get("/flags")
getFeatureFlags() {
return this.featureService.getFeatures();
}
}
-11
View File
@@ -1,11 +0,0 @@
import { Module } from "@nestjs/common";
import { FeatureController } from "./feature.controller";
import { FeatureService } from "./feature.service";
@Module({
providers: [FeatureService],
controllers: [FeatureController],
exports: [FeatureService],
})
export class FeatureModule {}
@@ -1,19 +0,0 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Config } from "../config/schema";
@Injectable()
export class FeatureService {
constructor(private readonly configService: ConfigService<Config>) {}
getFeatures() {
const isSignupsDisabled = this.configService.getOrThrow<boolean>("DISABLE_SIGNUPS");
const isEmailAuthDisabled = this.configService.getOrThrow<boolean>("DISABLE_EMAIL_AUTH");
return {
isSignupsDisabled,
isEmailAuthDisabled,
};
}
}
-21
View File
@@ -1,21 +0,0 @@
import { Injectable } from "@nestjs/common";
import { HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus";
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 this.printerService.getVersion();
return this.getStatus("browser", true, { version });
} catch (error) {
return this.getStatus("browser", false, { message: error.message });
}
}
}
-20
View File
@@ -1,20 +0,0 @@
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 });
}
}
}
@@ -1,39 +0,0 @@
import { Controller, Get, NotFoundException } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { HealthCheck, HealthCheckService } from "@nestjs/terminus";
import { configSchema } from "../config/schema";
import { BrowserHealthIndicator } from "./browser.health";
import { DatabaseHealthIndicator } from "./database.health";
import { StorageHealthIndicator } from "./storage.health";
@ApiTags("Health")
@Controller("health")
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly database: DatabaseHealthIndicator,
private readonly browser: BrowserHealthIndicator,
private readonly storage: StorageHealthIndicator,
) {}
private run() {
return this.health.check([
() => this.database.isHealthy(),
() => this.storage.isHealthy(),
() => this.browser.isHealthy(),
]);
}
@Get()
@HealthCheck()
check() {
return this.run();
}
@Get("environment")
environment() {
if (process.env.NODE_ENV === "production") throw new NotFoundException();
return configSchema.parse(process.env);
}
}
-16
View File
@@ -1,16 +0,0 @@
import { Module } from "@nestjs/common";
import { TerminusModule } from "@nestjs/terminus";
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],
controllers: [HealthController],
providers: [DatabaseHealthIndicator, BrowserHealthIndicator, StorageHealthIndicator],
})
export class HealthModule {}
-21
View File
@@ -1,21 +0,0 @@
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: unknown) {
return this.getStatus("storage", false, { message: (error as Error).message });
}
}
}
-37
View File
@@ -1,37 +0,0 @@
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 from = configService.get("MAIL_FROM");
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 {
defaults: { from },
transport: smtpUrl ?? emptyTransporter,
};
},
}),
],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
-25
View File
@@ -1,25 +0,0 @@
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) {
Logger.log(options, "MailService#sendEmail");
return;
}
return this.mailerService.sendMail(options);
}
}
-75
View File
@@ -1,75 +0,0 @@
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import cookieParser from "cookie-parser";
import session from "express-session";
import helmet from "helmet";
import { patchNestJsSwagger } from "nestjs-zod";
import { AppModule } from "./app.module";
import type { 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 accessTokenSecret = configService.getOrThrow("ACCESS_TOKEN_SECRET");
const publicUrl = configService.getOrThrow("PUBLIC_URL");
const isHTTPS = publicUrl.startsWith("https://") ?? false;
// Cookie Parser
app.use(cookieParser());
// Session
app.use(
session({
resave: false,
saveUninitialized: false,
secret: accessTokenSecret,
cookie: { httpOnly: true, secure: isHTTPS },
}),
);
// CORS
app.enableCors({ credentials: true, origin: isHTTPS });
// Helmet - enabled only in production
if (isHTTPS) 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");
}
// eslint-disable-next-line unicorn/prefer-top-level-await
void bootstrap();
-12
View File
@@ -1,12 +0,0 @@
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 {}
-277
View File
@@ -1,277 +0,0 @@
import { HttpService } from "@nestjs/axios";
import { Injectable, InternalServerErrorException, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ResumeDto } from "@reactive-resume/dto";
import { ErrorMessage } from "@reactive-resume/utils";
import retry from "async-retry";
import { PDFDocument } from "pdf-lib";
import { connect } from "puppeteer-core";
import { Config } from "../config/schema";
import { StorageService } from "../storage/storage.service";
@Injectable()
export class PrinterService {
private readonly logger = new Logger(PrinterService.name);
private readonly browserURL: string;
private readonly ignoreHTTPSErrors: boolean;
constructor(
private readonly configService: ConfigService<Config>,
private readonly storageService: StorageService,
private readonly httpService: HttpService,
) {
const chromeUrl = this.configService.getOrThrow<string>("CHROME_URL");
const chromeToken = this.configService.getOrThrow<string>("CHROME_TOKEN");
this.browserURL = `${chromeUrl}?token=${chromeToken}`;
this.ignoreHTTPSErrors = this.configService.getOrThrow<boolean>("CHROME_IGNORE_HTTPS_ERRORS");
}
private async getBrowser() {
try {
return await connect({
browserWSEndpoint: this.browserURL,
acceptInsecureCerts: this.ignoreHTTPSErrors,
});
} catch (error) {
throw new InternalServerErrorException(
ErrorMessage.InvalidBrowserConnection,
(error as Error).message,
);
}
}
async getVersion() {
const browser = await this.getBrowser();
const version = await browser.version();
await browser.disconnect();
return version;
}
async printResume(resume: ResumeDto) {
const start = performance.now();
const url = await retry<string | undefined>(() => this.generateResume(resume), {
retries: 3,
randomize: true,
onRetry: (_, attempt) => {
this.logger.log(`Retrying to print resume #${resume.id}, attempt #${attempt}`);
},
});
const duration = +(performance.now() - start).toFixed(0);
const numberPages = resume.data.metadata.layout.length;
this.logger.debug(`Chrome took ${duration}ms to print ${numberPages} page(s)`);
return url;
}
async printPreview(resume: ResumeDto) {
const start = performance.now();
const url = await retry(() => this.generatePreview(resume), {
retries: 3,
randomize: true,
onRetry: (_, attempt) => {
this.logger.log(
`Retrying to generate preview of resume #${resume.id}, attempt #${attempt}`,
);
},
});
const duration = +(performance.now() - start).toFixed(0);
this.logger.debug(`Chrome took ${duration}ms to generate preview`);
return url;
}
async generateResume(resume: ResumeDto) {
try {
const browser = await this.getBrowser();
const page = await browser.newPage();
const publicUrl = this.configService.getOrThrow<string>("PUBLIC_URL");
const storageUrl = this.configService.getOrThrow<string>("STORAGE_URL");
let url = publicUrl;
if ([publicUrl, storageUrl].some((url) => /https?:\/\/localhost(:\d+)?/.test(url))) {
// Switch client URL from `http[s]://localhost[:port]` to `http[s]://host.docker.internal[:port]` 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(:\d+)?/,
(_match, port) => `host.docker.internal${port ?? ""}`,
);
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(:\d+)?/, (_match, port) => `host.docker.internal${port ?? ""}`);
void request.continue({ url: modifiedUrl });
} else {
void request.continue();
}
});
}
// Set the data of the resume to be printed in the browser's session storage
const numberPages = resume.data.metadata.layout.length;
await page.goto(`${url}/artboard/preview`, { waitUntil: "domcontentloaded" });
await page.evaluate((data) => {
window.localStorage.setItem("resume", JSON.stringify(data));
}, resume.data);
await Promise.all([
page.reload({ waitUntil: "load" }),
// Wait until first page is present before proceeding
page.waitForSelector('[data-page="1"]', { timeout: 15_000 }),
]);
const pagesBuffer: Buffer[] = [];
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 temporaryHtml = await page.evaluate((element: HTMLDivElement) => {
const clonedElement = element.cloneNode(true) as HTMLDivElement;
const temporaryHtml_ = document.body.innerHTML;
document.body.innerHTML = clonedElement.outerHTML;
return temporaryHtml_;
}, pageElement);
// Apply custom CSS, if enabled
const css = resume.data.metadata.css;
if (css.visible) {
await page.evaluate((cssValue: string) => {
const styleTag = document.createElement("style");
styleTag.textContent = cssValue;
document.head.append(styleTag);
}, css.value);
}
const uint8array = await page.pdf({ width, height, printBackground: true });
const buffer = Buffer.from(uint8array);
pagesBuffer.push(buffer);
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 <= numberPages; index++) {
await processPage(index);
}
// Using 'pdf-lib', merge all the pages from their buffers into a single PDF
const pdf = await PDFDocument.create();
for (const element of pagesBuffer) {
const page = await PDFDocument.load(element);
const [copiedPage] = await pdf.copyPages(page, [0]);
pdf.addPage(copiedPage);
}
// 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.title,
);
// Close all the pages and disconnect from the browser
await page.close();
await browser.disconnect();
return resumeUrl;
} catch (error) {
this.logger.error(error);
throw new InternalServerErrorException(
ErrorMessage.ResumePrinterError,
(error as Error).message,
);
}
}
async generatePreview(resume: ResumeDto) {
const browser = await this.getBrowser();
const page = await browser.newPage();
const publicUrl = this.configService.getOrThrow<string>("PUBLIC_URL");
const storageUrl = this.configService.getOrThrow<string>("STORAGE_URL");
let url = publicUrl;
if ([publicUrl, storageUrl].some((url) => /https?:\/\/localhost(:\d+)?/.test(url))) {
// Switch client URL from `http[s]://localhost[:port]` to `http[s]://host.docker.internal[:port]` 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(:\d+)?/, (_match, port) => `host.docker.internal${port ?? ""}`);
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(:\d+)?/, (_match, port) => `host.docker.internal${port ?? ""}`);
void request.continue({ url: modifiedUrl });
} else {
void request.continue();
}
});
}
// Set the data of the resume to be printed in the browser's session storage
await page.evaluateOnNewDocument((data) => {
window.localStorage.setItem("resume", JSON.stringify(data));
}, resume.data);
await page.setViewport({ width: 794, height: 1123 });
await page.goto(`${url}/artboard/preview`, { 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 uint8array = await page.screenshot({ quality: 80, type: "jpeg" });
const buffer = Buffer.from(uint8array);
// 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();
await browser.disconnect();
return previewUrl;
}
}
@@ -1,12 +0,0 @@
import type { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
import type { ResumeDto } from "@reactive-resume/dto";
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;
},
);
@@ -1,42 +0,0 @@
import { CanActivate, ExecutionContext, Injectable, NotFoundException } from "@nestjs/common";
import { UserWithSecrets } from "@reactive-resume/dto";
import { ErrorMessage } from "@reactive-resume/utils";
import { Request } from "express";
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.id === resume.userId) {
request.payload = { resume };
} else {
throw new NotFoundException(ErrorMessage.ResumeNotFound);
}
}
return true;
} catch {
throw new NotFoundException(ErrorMessage.ResumeNotFound);
}
}
}
-177
View File
@@ -1,177 +0,0 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
InternalServerErrorException,
Logger,
Param,
Patch,
Post,
UseGuards,
} from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { User as UserEntity } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import {
CreateResumeDto,
importResumeSchema,
ResumeDto,
UpdateResumeDto,
} from "@reactive-resume/dto";
import { resumeDataSchema } from "@reactive-resume/schema";
import { ErrorMessage } from "@reactive-resume/utils";
import set from "lodash.set";
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 { 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")
getSchema() {
return zodToJsonSchema(resumeDataSchema);
}
@Post()
@UseGuards(TwoFactorGuard)
async create(@User() user: UserEntity, @Body() createResumeDto: CreateResumeDto) {
try {
return await this.resumeService.create(user.id, createResumeDto);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
throw new BadRequestException(ErrorMessage.ResumeSlugAlreadyExists);
}
Logger.error(error);
throw new InternalServerErrorException(error);
}
}
@Post("import")
@UseGuards(TwoFactorGuard)
async import(@User() user: UserEntity, @Body() importResumeDto: unknown) {
try {
const result = importResumeSchema.parse(importResumeDto);
return await this.resumeService.import(user.id, result);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
throw new BadRequestException(ErrorMessage.ResumeSlugAlreadyExists);
}
Logger.error(error);
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)
findOneStatistics(@Param("id") id: string) {
return this.resumeService.findOneStatistics(id);
}
@Get("/public/:username/:slug/stealthily")
@UseGuards(OptionalGuard)
async findOneByUsernameSlugStealthily(
@Param("username") username: string,
@Param("slug") slug: string,
) {
const resume = await this.resumeService.findOneByUsernameSlug(username, slug);
redactPrivateNotes(resume as ResumeDto);
return resume;
}
@Get("/public/:username/:slug")
@UseGuards(OptionalGuard)
async findOneByUsernameSlug(
@Param("username") username: string,
@Param("slug") slug: string,
@User("id") userId: string,
) {
const resume = await this.resumeService.findOneByUsernameSlug(username, slug);
redactPrivateNotes(resume as ResumeDto);
if (!userId) {
await this.resumeService.incrementViewCountForOne(resume.id);
}
return resume;
}
@Patch(":id")
@UseGuards(TwoFactorGuard)
update(
@User() user: UserEntity,
@Param("id") id: string,
@Body() updateResumeDto: UpdateResumeDto,
) {
return this.resumeService.update(user.id, id, updateResumeDto);
}
@Patch(":id/lock")
@UseGuards(TwoFactorGuard)
lock(@User() user: UserEntity, @Param("id") id: string, @Body("set") set = true) {
return this.resumeService.lock(user.id, id, set);
}
@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)
async printResume(@User("id") userId: string | undefined, @Resume() resume: ResumeDto) {
try {
const url = await this.resumeService.printResume(resume, userId);
return { url };
} catch (error) {
Logger.error(error);
throw new InternalServerErrorException(error);
}
}
@Get("/print/:id/preview")
@UseGuards(TwoFactorGuard, ResumeGuard)
async printPreview(@Resume() resume: ResumeDto) {
try {
const url = await this.resumeService.printPreview(resume);
return { url };
} catch (error) {
Logger.error(error);
throw new InternalServerErrorException(error);
}
}
}
function redactPrivateNotes(resume: ResumeDto) {
set(resume.data, "metadata.notes", undefined);
}
-16
View File
@@ -1,16 +0,0 @@
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 {}
-163
View File
@@ -1,163 +0,0 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
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 { ErrorMessage, generateRandomName } from "@reactive-resume/utils";
import slugify from "@sindresorhus/slugify";
import deepmerge from "deepmerge";
import { PrismaService } from "nestjs-prisma";
import { PrinterService } from "@/server/printer/printer.service";
import { StorageService } from "../storage/storage.service";
@Injectable()
export class ResumeService {
constructor(
private readonly prisma: PrismaService,
private readonly printerService: PrinterService,
private readonly storageService: StorageService,
) {}
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>);
return this.prisma.resume.create({
data: {
data,
userId,
title: createResumeDto.title,
visibility: createResumeDto.visibility,
slug: createResumeDto.slug ?? slugify(createResumeDto.title),
},
});
}
import(userId: string, importResumeDto: ImportResumeDto) {
const randomTitle = generateRandomName();
return this.prisma.resume.create({
data: {
userId,
visibility: "private",
data: importResumeDto.data,
title: importResumeDto.title ?? randomTitle,
slug: importResumeDto.slug ?? slugify(randomTitle),
},
});
}
findAll(userId: string) {
return this.prisma.resume.findMany({ where: { userId }, orderBy: { updatedAt: "desc" } });
}
findOne(id: string, userId?: string) {
if (userId) {
return this.prisma.resume.findUniqueOrThrow({ where: { userId_id: { userId, id } } });
}
return this.prisma.resume.findUniqueOrThrow({ where: { id } });
}
async findOneStatistics(id: string) {
const result = await this.prisma.statistics.findFirst({
select: { views: true, downloads: true },
where: { resumeId: id },
});
return {
views: result?.views ?? 0,
downloads: result?.downloads ?? 0,
};
}
async findOneByUsernameSlug(username: string, slug: string) {
return await this.prisma.resume.findFirstOrThrow({
where: { user: { username }, slug, visibility: "public" },
});
}
async incrementViewCountForOne(resumeId: string) {
// Update statistics: increment the number of views by 1
await this.prisma.statistics.upsert({
where: { resumeId },
create: { views: 1, downloads: 0, resumeId },
update: { views: { increment: 1 } },
});
}
async update(userId: string, id: string, updateResumeDto: UpdateResumeDto) {
try {
const { locked } = await this.prisma.resume.findUniqueOrThrow({
where: { id },
select: { locked: true },
});
if (locked) throw new BadRequestException(ErrorMessage.ResumeLocked);
return await this.prisma.resume.update({
data: {
title: updateResumeDto.title,
slug: updateResumeDto.slug,
visibility: updateResumeDto.visibility,
data: updateResumeDto.data as Prisma.JsonObject,
},
where: { userId_id: { userId, id } },
});
} catch (error) {
if (error.code === "P2025") {
Logger.error(error);
throw new InternalServerErrorException(error);
}
}
}
lock(userId: string, id: string, set: boolean) {
return this.prisma.resume.update({
data: { locked: set },
where: { userId_id: { userId, id } },
});
}
async remove(userId: string, id: string) {
await Promise.all([
// 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, userId?: string) {
const url = await this.printerService.printResume(resume);
// Update statistics: increment the number of downloads by 1
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;
}
printPreview(resume: ResumeDto) {
return this.printerService.printPreview(resume);
}
}
@@ -1,34 +0,0 @@
import {
BadRequestException,
Controller,
Put,
UploadedFile,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { FileInterceptor } from "@nestjs/platform-express";
import { ApiTags } from "@nestjs/swagger";
import { TwoFactorGuard } from "@/server/auth/guards/two-factor.guard";
import { User } from "@/server/user/decorators/user.decorator";
import { StorageService } from "./storage.service";
@ApiTags("Storage")
@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, file.filename);
}
}
-28
View File
@@ -1,28 +0,0 @@
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>) => ({
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"),
useSSL: configService.getOrThrow<boolean>("STORAGE_USE_SSL"),
}),
}),
],
controllers: [StorageController],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}
-183
View File
@@ -1,183 +0,0 @@
import { Injectable, InternalServerErrorException, Logger, OnModuleInit } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createId } from "@paralleldrive/cuid2";
import slugify from "@sindresorhus/slugify";
import { MinioClient, 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";
export 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/*",
],
},
],
} as const;
@Injectable()
export class StorageService implements OnModuleInit {
private readonly logger = new Logger(StorageService.name);
private client: MinioClient;
private bucketName: string;
constructor(
private readonly configService: ConfigService<Config>,
private readonly minioService: MinioService,
) {}
async onModuleInit() {
this.client = this.minioService.client;
this.bucketName = this.configService.getOrThrow<string>("STORAGE_BUCKET");
const skipBucketCheck = this.configService.getOrThrow<boolean>("STORAGE_SKIP_BUCKET_CHECK");
if (skipBucketCheck) {
this.logger.warn("Skipping the verification of whether the storage bucket exists.");
this.logger.warn(
"Make sure that the following paths are publicly accessible: `/{pictures,previews,resumes}/*`",
);
return;
}
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) {
this.logger.log("Successfully connected to the storage service.");
} else {
const bucketPolicy = JSON.stringify(PUBLIC_ACCESS_POLICY).replace(
/{{bucketName}}/g,
this.bucketName,
);
try {
await this.client.makeBucket(this.bucketName);
} catch {
throw new InternalServerErrorException(
"There was an error while creating the storage bucket.",
);
}
try {
await this.client.setBucketPolicy(this.bucketName, bucketPolicy);
} catch {
throw new InternalServerErrorException(
"There was an error while applying the policy to the storage bucket.",
);
}
this.logger.log(
"A new storage bucket has been created and the policy has been applied successfully.",
);
}
} catch (error) {
throw new InternalServerErrorException(error);
}
}
async bucketExists(): Promise<true> {
const exists = await this.client.bucketExists(this.bucketName);
if (!exists) {
throw new InternalServerErrorException(
"There was an error while checking if the storage bucket exists.",
);
}
return exists;
}
async uploadObject(
userId: string,
type: UploadType,
buffer: Buffer,
filename: string = createId(),
): Promise<string> {
const extension = type === "resumes" ? "pdf" : "jpg";
const storageUrl = this.configService.getOrThrow<string>("STORAGE_URL");
let normalizedFilename = slugify(filename);
if (!normalizedFilename) normalizedFilename = createId();
const filepath = `${userId}/${type}/${normalizedFilename}.${extension}`;
const url = `${storageUrl}/${filepath}`;
const metadata =
extension === "jpg"
? { "Content-Type": "image/jpeg" }
: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename=${normalizedFilename}.${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 this.client.putObject(this.bucketName, filepath, buffer, metadata);
return url;
} catch {
throw new InternalServerErrorException("There was an error while uploading the file.");
}
}
async deleteObject(userId: string, type: UploadType, filename: string): Promise<void> {
const extension = type === "resumes" ? "pdf" : "jpg";
const path = `${userId}/${type}/${filename}.${extension}`;
try {
await this.client.removeObject(this.bucketName, path);
} catch {
throw new InternalServerErrorException(
`There was an error while deleting the document at the specified path: ${path}.`,
);
}
}
async deleteFolder(prefix: string): Promise<void> {
const objectsList = [];
const objectsStream = this.client.listObjectsV2(this.bucketName, prefix, true);
for await (const object of objectsStream) {
objectsList.push(object.name);
}
try {
await this.client.removeObjects(this.bucketName, objectsList);
} catch {
throw new InternalServerErrorException(
`There was an error while deleting the folder at the specified path: ${this.bucketName}/${prefix}.`,
);
}
}
}
@@ -1,13 +0,0 @@
import { Controller, Get } from "@nestjs/common";
import { TranslationService } from "./translation.service";
@Controller("translation")
export class TranslationController {
constructor(private readonly translationService: TranslationService) {}
@Get("/languages")
async languages() {
return this.translationService.fetchLanguages();
}
}
@@ -1,12 +0,0 @@
import { HttpModule } from "@nestjs/axios";
import { Module } from "@nestjs/common";
import { TranslationController } from "./translation.controller";
import { TranslationService } from "./translation.service";
@Module({
imports: [HttpModule],
controllers: [TranslationController],
providers: [TranslationService],
})
export class TranslationModule {}
@@ -1,63 +0,0 @@
import { HttpService } from "@nestjs/axios";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Language, languages } from "@reactive-resume/utils";
import { Config } from "../config/schema";
type CrowdinResponse = {
data: {
data: {
language: { id: string; name: string; locale: string; editorCode: string };
translationProgress: number;
};
}[];
};
@Injectable()
export class TranslationService {
constructor(
private readonly httpService: HttpService,
private readonly configService: ConfigService<Config>,
) {}
async fetchLanguages() {
try {
const projectId = this.configService.getOrThrow("CROWDIN_PROJECT_ID");
const accessToken = this.configService.getOrThrow("CROWDIN_PERSONAL_TOKEN");
const response = await this.httpService.axiosRef.get(
`https://api.crowdin.com/api/v2/projects/${projectId}/languages/progress?limit=100`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
const { data } = response.data as CrowdinResponse;
// Add English Locale
data.push({
data: {
language: {
id: "en-US",
locale: "en-US",
editorCode: "en",
name: "English",
},
translationProgress: 100,
},
});
data.sort((a, b) => a.data.language.name.localeCompare(b.data.language.name));
return data.map(({ data }) => {
return {
id: data.language.id,
name: data.language.name,
progress: data.translationProgress,
editorCode: data.language.editorCode,
locale: data.language.locale,
} satisfies Language;
});
} catch {
return languages;
}
}
}
-15
View File
@@ -1,15 +0,0 @@
import type { Resume, User as PrismaUser } from "@prisma/client";
declare global {
namespace Express {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Request {
user?: PrismaUser;
payload?: {
resume: Resume;
};
}
}
}
export {};
@@ -1,12 +0,0 @@
import type { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
import type { UserWithSecrets } from "@reactive-resume/dto";
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;
},
);
-80
View File
@@ -1,80 +0,0 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
InternalServerErrorException,
Logger,
Patch,
Res,
UseGuards,
} from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { UpdateUserDto, UserDto } from "@reactive-resume/dto";
import { ErrorMessage } from "@reactive-resume/utils";
import type { Response } from "express";
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)
fetch(@User() user: UserDto) {
return user;
}
@Patch("me")
@UseGuards(TwoFactorGuard)
async update(@User("email") email: string, @Body() updateUserDto: UpdateUserDto) {
try {
// 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 await this.userService.updateByEmail(email, {
name: updateUserDto.name,
picture: updateUserDto.picture,
username: updateUserDto.username,
locale: updateUserDto.locale,
});
} catch (error) {
if (error instanceof PrismaClientKnownRequestError && error.code === "P2002") {
throw new BadRequestException(ErrorMessage.UserAlreadyExists);
}
Logger.error(error);
throw new InternalServerErrorException(error);
}
}
@Delete("me")
@UseGuards(TwoFactorGuard)
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
View File
@@ -1,14 +0,0 @@
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 {}
-94
View File
@@ -1,94 +0,0 @@
import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { Prisma, User } from "@prisma/client";
import { UserWithSecrets } from "@reactive-resume/dto";
import { ErrorMessage } from "@reactive-resume/utils";
import { PrismaService } from "nestjs-prisma";
import { StorageService } from "../storage/storage.service";
@Injectable()
export class UserService {
constructor(
private readonly prisma: PrismaService,
private readonly storageService: StorageService,
) {}
async findOneById(id: string): Promise<UserWithSecrets> {
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): Promise<UserWithSecrets | null> {
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.findUnique({
where: { username: identifier },
include: { secrets: true },
});
})(identifier);
return user;
}
async findOneByIdentifierOrThrow(identifier: string): Promise<UserWithSecrets> {
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;
}
create(data: Prisma.UserCreateInput): Promise<UserWithSecrets> {
return this.prisma.user.create({ data, include: { secrets: true } });
}
updateByEmail(email: string, data: Prisma.UserUpdateArgs["data"]): Promise<User> {
return this.prisma.user.update({ where: { email }, data });
}
async updateByResetToken(
resetToken: string,
data: Prisma.SecretsUpdateArgs["data"],
): Promise<void> {
await this.prisma.secrets.update({ where: { resetToken }, data });
}
async deleteOneById(id: string): Promise<void> {
await Promise.all([
this.storageService.deleteFolder(id),
this.prisma.user.delete({ where: { id } }),
]);
}
}