feat: added biome on pre-commit

This commit is contained in:
Hackntosh 2024-02-03 14:41:32 +00:00
parent 6f7aafef81
commit 66681673bc
41 changed files with 2530 additions and 772 deletions

View file

@ -1,5 +1,5 @@
if (process.env.NODE_ENV === 'production' || process.env.CI === 'true') {
process.exit(0)
if (process.env.NODE_ENV === "production" || process.env.CI === "true") {
process.exit(0);
}
const husky = (await import('husky')).default
console.log(husky())
const husky = (await import("husky")).default;
console.log(husky());

1
.husky/pre-commit Normal file
View file

@ -0,0 +1 @@
lint-staged

26
.swcrc
View file

@ -1,14 +1,14 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"baseUrl": "./"
},
"minify": false
}
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"baseUrl": "./"
},
"minify": false
}

View file

@ -1,5 +1,3 @@
{
"recommendations": [
"biomejs.biome"
]
}
"recommendations": ["biomejs.biome"]
}

View file

@ -1,3 +1,3 @@
module.exports = {
extends: ['@commitlint/config-conventional'],
extends: ["@commitlint/config-conventional"],
};

View file

@ -1,10 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
}
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
}
}

1753
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -61,6 +61,8 @@
},
"devDependencies": {
"@biomejs/biome": "1.5.3",
"@commitlint/cli": "^18.6.0",
"@commitlint/config-conventional": "^18.6.0",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
@ -81,6 +83,7 @@
"eslint-plugin-prettier": "^5.0.0",
"husky": "^9.0.7",
"jest": "^29.5.0",
"lint-staged": "^15.2.1",
"prettier": "^3.0.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
@ -108,5 +111,8 @@
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"lint-staged": {
"**.{js|ts|json}": "biome check --apply --no-errors-on-unmatched"
}
}

View file

@ -92,4 +92,4 @@ model Notifications {
enum NotificationType {
WARNING
INFO
}
}

View file

@ -1,56 +1,56 @@
import { FastifyMulterModule } from "@nest-lab/fastify-multer";
import { Module } from "@nestjs/common";
import { UserModule } from "./users/users.module";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_PIPE } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { S3Module } from "nestjs-s3";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { ZodValidationPipe } from "nestjs-zod";
import { AuthModule } from "./auth/auth.module";
import { ConfigModule } from "@nestjs/config";
import { JwtAuthGuard } from "./auth/jwt-auth.guard";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { KweeksModule } from "./kweeks/kweeks.module";
import { FastifyMulterModule } from "@nest-lab/fastify-multer";
import { S3Module } from "nestjs-s3";
import { UserModule } from "./users/users.module";
@Module({
imports: [
UserModule,
AuthModule,
ConfigModule.forRoot({
isGlobal: true,
}),
ThrottlerModule.forRoot({
throttlers: [{ limit: 10, ttl: 60000 }],
storage: new ThrottlerStorageRedisService(
`redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}/0`,
),
}),
KweeksModule,
FastifyMulterModule,
S3Module.forRoot({
config: {
credentials: {
accessKeyId: process.env.MINIO_ROOT_USER, // CHANGE WHEN PRODUCTION TO S3
secretAccessKey: process.env.MINIO_ROOT_PASSWORD,
},
region: "us-east-1",
endpoint: process.env.MINIO_ENDPOINT,
forcePathStyle: true,
},
}),
],
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
imports: [
UserModule,
AuthModule,
ConfigModule.forRoot({
isGlobal: true,
}),
ThrottlerModule.forRoot({
throttlers: [{ limit: 10, ttl: 60000 }],
storage: new ThrottlerStorageRedisService(
`redis://:${process.env.REDIS_PASSWORD}@${process.env.REDIS_HOST}:${process.env.REDIS_PORT}/0`,
),
}),
KweeksModule,
FastifyMulterModule,
S3Module.forRoot({
config: {
credentials: {
accessKeyId: process.env.MINIO_ROOT_USER, // CHANGE WHEN PRODUCTION TO S3
secretAccessKey: process.env.MINIO_ROOT_PASSWORD,
},
region: "us-east-1",
endpoint: process.env.MINIO_ENDPOINT,
forcePathStyle: true,
},
}),
],
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}

View file

@ -1,35 +1,35 @@
import {
Controller,
Request,
Post,
UseGuards,
Body,
HttpCode,
Body,
Controller,
HttpCode,
Post,
Request,
UseGuards,
} from "@nestjs/common";
import {
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { LocalAuthGuard } from "./local-auth.guard";
import { Public } from "src/public.decorator";
import { AuthService } from "./auth.service";
import { LoginUserDTO } from "./dto/login.dto";
import { Public } from "src/public.decorator";
import { LocalAuthGuard } from "./local-auth.guard";
@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
constructor(private authService: AuthService) {}
@Public()
@UseGuards(LocalAuthGuard)
@Post("/")
@ApiOperation({ summary: "Authenticates a user" })
@ApiOkResponse({ status: 200, description: "Authenticated successfully" })
@ApiUnauthorizedResponse({ description: "Wrong username or password" })
@HttpCode(200)
async login(@Request() req, @Body() _: LoginUserDTO) {
return this.authService.login(req.user);
}
@Public()
@UseGuards(LocalAuthGuard)
@Post("/")
@ApiOperation({ summary: "Authenticates a user" })
@ApiOkResponse({ status: 200, description: "Authenticated successfully" })
@ApiUnauthorizedResponse({ description: "Wrong username or password" })
@HttpCode(200)
async login(@Request() req, @Body() _: LoginUserDTO) {
return this.authService.login(req.user);
}
}

View file

@ -1,22 +1,22 @@
import { Module } from "@nestjs/common";
import { AuthService } from "./auth.service";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { LocalStrategy } from "./local.strategy";
import { UserModule } from "src/users/users.module";
import { AuthController } from "./auth.controller";
import { JwtModule } from "@nestjs/jwt";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { LocalStrategy } from "./local.strategy";
@Module({
controllers: [AuthController],
imports: [
UserModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET,
signOptions: { expiresIn: "1d" }, // TODO: add refresh tokens
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
controllers: [AuthController],
imports: [
UserModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET,
signOptions: { expiresIn: "1d" }, // TODO: add refresh tokens
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}

View file

@ -1,46 +1,46 @@
import { Injectable } from "@nestjs/common";
import { UserService } from "src/users/users.service";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt";
import { UserModel } from "src/users/models/user.model";
import { JwtService } from "@nestjs/jwt";
import { UserService } from "src/users/users.service";
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async validateUser(
username: string,
password: string,
): Promise<UserModel | null> {
const user = await this.userService.auth_search(username);
async validateUser(
username: string,
password: string,
): Promise<UserModel | null> {
const user = await this.userService.auth_search(username);
if (user === undefined) {
return null;
}
if (user === undefined) {
return null;
}
const validation = await bcrypt.compare(password, user.password);
const validation = await bcrypt.compare(password, user.password);
if (user && validation) {
const { password, ...result } = user;
return result;
}
if (user && validation) {
const { password, ...result } = user;
return result;
}
return null;
}
return null;
}
async login(user: UserModel): Promise<{ token: string }> {
const payload = {
displayName: user.displayName,
username: user.username,
profileImage: user.profileImage,
sub: user.id,
};
async login(user: UserModel): Promise<{ token: string }> {
const payload = {
displayName: user.displayName,
username: user.username,
profileImage: user.profileImage,
sub: user.id,
};
return {
token: this.jwtService.sign(payload),
};
}
return {
token: this.jwtService.sign(payload),
};
}
}

View file

@ -2,28 +2,28 @@ import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const LoginUserSchema = z
.object({
username: z
.string({
required_error: "Username is required",
})
.regex(
/^[a-zA-Z0-9_.]{5,15}$/,
"The username must have alphanumerics characters, underscore, dots and it must be between 5 and 15 characters",
)
.toLowerCase(),
password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")), // Removes every whitespace
})
.required();
.object({
username: z
.string({
required_error: "Username is required",
})
.regex(
/^[a-zA-Z0-9_.]{5,15}$/,
"The username must have alphanumerics characters, underscore, dots and it must be between 5 and 15 characters",
)
.toLowerCase(),
password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")), // Removes every whitespace
})
.required();
export class LoginUserDTO extends createZodDto(LoginUserSchema) {}

View file

@ -6,20 +6,20 @@ import { IS_PUBLIC_KEY } from "src/public.decorator";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) {
super();
}
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

View file

@ -3,26 +3,26 @@ import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
type Payload = {
displayName: string;
username: string;
sub: string;
displayName: string;
username: string;
sub: string;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET,
});
}
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET,
});
}
async validate(payload: Payload) {
return {
displayName: payload.displayName,
username: payload.username,
id: payload.sub,
};
}
async validate(payload: Payload) {
return {
displayName: payload.displayName,
username: payload.username,
id: payload.sub,
};
}
}

View file

@ -1,20 +1,20 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { AuthService } from "./auth.service";
import { UserModel } from "src/users/models/user.model";
import { AuthService } from "./auth.service";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<UserModel> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException("Wrong username or password");
}
return user;
}
async validate(username: string, password: string): Promise<UserModel> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException("Wrong username or password");
}
return user;
}
}

View file

@ -2,10 +2,10 @@ import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const CreateKweekSchema = z
.object({
content: z.string({ required_error: "Kweek content is required" }).max(300),
files: z.array(z.object({})),
})
.required();
.object({
content: z.string({ required_error: "Kweek content is required" }).max(300),
files: z.array(z.object({})),
})
.required();
export class CreateKweekDTO extends createZodDto(CreateKweekSchema) {}

View file

@ -1,4 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateKweekDto } from './create-kweek.dto';
import { PartialType } from "@nestjs/swagger";
import { CreateKweekDto } from "./create-kweek.dto";
export class UpdateKweekDto extends PartialType(CreateKweekDto) {}

View file

@ -37,7 +37,7 @@ export class KweeksController {
@UploadedFiles() attachments: File,
@Request() req,
) {
return this.kweeksService.create(createKweekDto);
return this.kweeksService.create(createKweekDto);
}
@Public()

View file

@ -1,11 +1,11 @@
import { Module } from "@nestjs/common";
import { KweeksService } from "./kweeks.service";
import { KweeksController } from "./kweeks.controller";
import { PrismaModule } from "src/prisma/prisma.module";
import { KweeksController } from "./kweeks.controller";
import { KweeksService } from "./kweeks.service";
@Module({
imports: [PrismaModule],
controllers: [KweeksController],
providers: [KweeksService],
imports: [PrismaModule],
controllers: [KweeksController],
providers: [KweeksService],
})
export class KweeksModule {}

View file

@ -1,24 +1,24 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateKweekDTO } from "./dto/create-kweek.dto";
import { UpdateKweekDto } from "./dto/update-kweek.dto";
import { PrismaService } from "src/prisma/prisma.service";
@Injectable()
export class KweeksService {
constructor(private readonly prisma: PrismaService) {}
create(createKweekDto: CreateKweekDTO) {
return "This action adds a new kweek";
}
constructor(private readonly prisma: PrismaService) {}
create(createKweekDto: CreateKweekDTO) {
return "This action adds a new kweek";
}
findOne(id: number) {
return `This action returns a #${id} kweek`;
}
findOne(id: number) {
return `This action returns a #${id} kweek`;
}
update(id: number, updateKweekDto: UpdateKweekDto) {
return `This action updates a #${id} kweek`;
}
update(id: number, updateKweekDto: UpdateKweekDto) {
return `This action updates a #${id} kweek`;
}
remove(id: number) {
return `This action removes a #${id} kweek`;
}
remove(id: number) {
return `This action removes a #${id} kweek`;
}
}

View file

@ -1,51 +1,51 @@
import { NestFactory } from "@nestjs/core";
import { SwaggerModule, DocumentBuilder } from "@nestjs/swagger";
import { AppModule } from "./app.module";
import { patchNestJsSwagger } from "nestjs-zod";
import {
FastifyAdapter,
NestFastifyApplication,
} from "@nestjs/platform-fastify";
import * as helmet from "@fastify/helmet";
import { NestFactory } from "@nestjs/core";
import {
FastifyAdapter,
NestFastifyApplication,
} from "@nestjs/platform-fastify";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { patchNestJsSwagger } from "nestjs-zod";
import { AppModule } from "./app.module";
// TODO: File Upload (Posts and User Profile Image)
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
);
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: true }),
);
patchNestJsSwagger();
patchNestJsSwagger();
app.enableCors();
app.enableCors();
const config = new DocumentBuilder()
.setTitle("Project Knedita")
.setDescription("An open-source social media")
.setVersion("1.0")
.addBearerAuth(
{
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
name: "JWT",
description: "Enter JWT Token",
in: "header",
},
"JWT",
)
.addTag("Auth")
.addTag("Kweeks")
.addTag("Users")
.build();
const config = new DocumentBuilder()
.setTitle("Project Knedita")
.setDescription("An open-source social media")
.setVersion("1.0")
.addBearerAuth(
{
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
name: "JWT",
description: "Enter JWT Token",
in: "header",
},
"JWT",
)
.addTag("Auth")
.addTag("Kweeks")
.addTag("Users")
.build();
const document = SwaggerModule.createDocument(app, config);
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("/", app, document);
SwaggerModule.setup("/", app, document);
await app.register(helmet);
await app.register(helmet);
await app.listen(process.env.SERVER_PORT, process.env.SERVER_HOST);
await app.listen(process.env.SERVER_PORT, process.env.SERVER_HOST);
}
bootstrap();

View file

@ -2,7 +2,7 @@ import { Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Module({
providers: [PrismaService],
exports: [PrismaService],
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View file

@ -3,16 +3,16 @@ import { Prisma, PrismaClient } from "@prisma/client";
@Injectable()
export class PrismaService
extends PrismaClient<Prisma.PrismaClientOptions, "beforeExit">
implements OnModuleInit
extends PrismaClient<Prisma.PrismaClientOptions, "beforeExit">
implements OnModuleInit
{
async onModuleInit() {
await this.$connect();
}
async onModuleInit() {
await this.$connect();
}
async enableShutdownHooks(app: INestApplication) {
this.$on("beforeExit", async () => {
await app.close();
});
}
async enableShutdownHooks(app: INestApplication) {
this.$on("beforeExit", async () => {
await app.close();
});
}
}

View file

@ -2,33 +2,33 @@ import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const CreateUserSchema = z
.object({
username: z
.string({
required_error: "Username is required",
})
.regex(
/^[a-zA-Z0-9_.]{5,15}$/,
"The username must have alphanumerics characters, underscore, dots and it must be between 5 and 15 characters",
)
.toLowerCase(),
email: z
.string({
required_error: "Email is required",
})
.email("Invalid email"),
password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")), // Removes every whitespace
})
.required();
.object({
username: z
.string({
required_error: "Username is required",
})
.regex(
/^[a-zA-Z0-9_.]{5,15}$/,
"The username must have alphanumerics characters, underscore, dots and it must be between 5 and 15 characters",
)
.toLowerCase(),
email: z
.string({
required_error: "Email is required",
})
.email("Invalid email"),
password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")), // Removes every whitespace
})
.required();
export class CreateUserDTO extends createZodDto(CreateUserSchema) {}

View file

@ -2,13 +2,13 @@ import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const UpdateEmailSchema = z
.object({
email: z
.string({
required_error: "Email is required",
})
.email("Invalid email"),
})
.required();
.object({
email: z
.string({
required_error: "Email is required",
})
.email("Invalid email"),
})
.required();
export class UpdateEmailDTO extends createZodDto(UpdateEmailSchema) {}

View file

@ -2,19 +2,19 @@ import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const UpdateNameSchema = z
.object({
username: z
.string()
.regex(
/^[a-zA-Z0-9_.]{5,15}$/,
"The username must have alphanumerics characters, underscore, dots and it must be between 5 and 15 characters",
)
.toLowerCase()
.describe("New username - optional")
.optional()
.or(z.literal("")),
displayName: z.string({ required_error: "Display name is required" }),
})
.required();
.object({
username: z
.string()
.regex(
/^[a-zA-Z0-9_.]{5,15}$/,
"The username must have alphanumerics characters, underscore, dots and it must be between 5 and 15 characters",
)
.toLowerCase()
.describe("New username - optional")
.optional()
.or(z.literal("")),
displayName: z.string({ required_error: "Display name is required" }),
})
.required();
export class UpdateNameDTO extends createZodDto(UpdateNameSchema) {}

View file

@ -4,30 +4,30 @@ import { z } from "nestjs-zod/z";
// TODO: see if it can be refactored
export const UpdatePasswordSchema = z
.object({
old_password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")),
new_password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")),
})
.required();
.object({
old_password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")),
new_password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")),
})
.required();
export class UpdatePasswordDTO extends createZodDto(UpdatePasswordSchema) {}

View file

@ -2,21 +2,21 @@ import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const UserSchema = z
.object({
id: z.string().uuid(),
displayName: z.string().optional(),
username: z.string(),
email: z.string().email(),
password: z.password(),
kweeks: z.array(z.object({})).optional(),
profileImage: z.string().url().optional(),
likedKweeks: z.array(z.object({})).optional(),
likedComments: z.array(z.object({})).optional(),
followers: z.number(),
following: z.number(),
kweeksComments: z.array(z.object({})).optional(),
createdAt: z.date(),
})
.required();
.object({
id: z.string().uuid(),
displayName: z.string().optional(),
username: z.string(),
email: z.string().email(),
password: z.password(),
kweeks: z.array(z.object({})).optional(),
profileImage: z.string().url().optional(),
likedKweeks: z.array(z.object({})).optional(),
likedComments: z.array(z.object({})).optional(),
followers: z.number(),
following: z.number(),
kweeksComments: z.array(z.object({})).optional(),
createdAt: z.date(),
})
.required();
export class UserModel extends createZodDto(UserSchema) {}

View file

@ -5,34 +5,34 @@ import sharp from "sharp";
@Injectable()
export class S3Service {
constructor(@InjectS3() private readonly s3: S3) {}
constructor(@InjectS3() private readonly s3: S3) {}
/**
* Returns the image url if the upload to minio was successful.
*/
async uploadImageToMinio(userID: string, buffer: Buffer): Promise<string> {
const compressedBuffer = await sharp(buffer)
.resize(200, 200)
.webp({ quality: 70 })
.toBuffer();
/**
* Returns the image url if the upload to minio was successful.
*/
async uploadImageToMinio(userID: string, buffer: Buffer): Promise<string> {
const compressedBuffer = await sharp(buffer)
.resize(200, 200)
.webp({ quality: 70 })
.toBuffer();
const uploadParams: PutObjectCommandInput = {
Bucket: process.env.MINIO_DEFAULT_BUCKETS,
Key: `profile_images/${userID}.webp`,
Body: compressedBuffer,
ContentType: "image/webp",
ContentDisposition: "inline",
ACL: "public-read",
};
const uploadParams: PutObjectCommandInput = {
Bucket: process.env.MINIO_DEFAULT_BUCKETS,
Key: `profile_images/${userID}.webp`,
Body: compressedBuffer,
ContentType: "image/webp",
ContentDisposition: "inline",
ACL: "public-read",
};
const { ETag } = await this.s3.send(new PutObjectCommand(uploadParams));
const { ETag } = await this.s3.send(new PutObjectCommand(uploadParams));
if (ETag !== null) {
return `${process.env.MINIO_ENDPOINT}/${process.env.MINIO_DEFAULT_BUCKETS}/profile_images/${userID}.webp`;
}
if (ETag !== null) {
return `${process.env.MINIO_ENDPOINT}/${process.env.MINIO_DEFAULT_BUCKETS}/profile_images/${userID}.webp`;
}
throw new InternalServerErrorException(
"Failed to upload the profile image",
);
}
throw new InternalServerErrorException(
"Failed to upload the profile image",
);
}
}

View file

@ -1,14 +1,14 @@
const UploadImageSchema = {
required: true,
schema: {
type: "object",
properties: {
image: {
type: "string",
format: "binary",
},
},
},
required: true,
schema: {
type: "object",
properties: {
image: {
type: "string",
format: "binary",
},
},
},
};
export default UploadImageSchema;

View file

@ -1,5 +1,5 @@
export type User = {
displayName: string;
username: string;
id: string;
displayName: string;
username: string;
id: string;
};

View file

@ -1,129 +1,129 @@
import { File, FileInterceptor } from "@nest-lab/fastify-multer";
import {
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Request,
UploadedFile,
UseInterceptors,
Body,
Controller,
Delete,
Get,
HttpCode,
Param,
Patch,
Post,
Request,
UploadedFile,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiCreatedResponse,
ApiNotFoundResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiCreatedResponse,
ApiNotFoundResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { UserService } from "./users.service";
import { CreateUserDTO } from "./dto/create-user.dto";
import { Public } from "src/public.decorator";
import { UpdateNameDTO } from "./dto/update-name.dto";
import { User } from "./types/user.type";
import { UpdateEmailDTO } from "./dto/update-email.dto";
import { UpdatePasswordDTO } from "./dto/update-password.dto";
import { File, FileInterceptor } from "@nest-lab/fastify-multer";
import { BufferValidator } from "src/validators/buffer-validator.pipe";
import UploadImageSchema from "./schemas/upload-image.schema";
import UploadImageValidator from "src/validators/upload-image.validator";
import { CreateUserDTO } from "./dto/create-user.dto";
import { UpdateEmailDTO } from "./dto/update-email.dto";
import { UpdateNameDTO } from "./dto/update-name.dto";
import { UpdatePasswordDTO } from "./dto/update-password.dto";
import UploadImageSchema from "./schemas/upload-image.schema";
import { User } from "./types/user.type";
import { UserService } from "./users.service";
@ApiTags("Users")
@Controller("users")
export class UserController {
constructor(private readonly userService: UserService) {}
// POST
@Public()
@Post()
@ApiOperation({ summary: "Creates a new account" })
@ApiCreatedResponse({ description: "Account created successfully" })
@ApiBadRequestResponse({
description:
"Missing field / Invalid username / Invalid email / Weak password",
})
create(@Body() createUserDTO: CreateUserDTO) {
return this.userService.create(createUserDTO);
}
constructor(private readonly userService: UserService) {}
// POST
@Public()
@Post()
@ApiOperation({ summary: "Creates a new account" })
@ApiCreatedResponse({ description: "Account created successfully" })
@ApiBadRequestResponse({
description:
"Missing field / Invalid username / Invalid email / Weak password",
})
create(@Body() createUserDTO: CreateUserDTO) {
return this.userService.create(createUserDTO);
}
// GET
@Get("/profile")
@ApiOperation({ summary: "Returns information about the logged user" })
@ApiBearerAuth("JWT")
@ApiUnauthorizedResponse({
description: "Not authenticated / Invalid JWT Token",
})
me(@Request() req) {
return req.user;
}
// GET
@Get("/profile")
@ApiOperation({ summary: "Returns information about the logged user" })
@ApiBearerAuth("JWT")
@ApiUnauthorizedResponse({
description: "Not authenticated / Invalid JWT Token",
})
me(@Request() req) {
return req.user;
}
@Public()
@Get(":username")
@ApiOperation({ summary: "Returns information about a user" })
@ApiNotFoundResponse({ description: "User not found" })
@HttpCode(200)
info(@Param("username") username: string) {
return this.userService.info(username);
}
@Public()
@Get(":username")
@ApiOperation({ summary: "Returns information about a user" })
@ApiNotFoundResponse({ description: "User not found" })
@HttpCode(200)
info(@Param("username") username: string) {
return this.userService.info(username);
}
// PATCH
@Patch()
@ApiOperation({
summary: "Updates the username or display name of a logged user",
})
@ApiBearerAuth("JWT")
updateName(@Body() { displayName, username }: UpdateNameDTO, @Request() req) {
return this.userService.updateName(req.user as User, username, displayName);
}
// PATCH
@Patch()
@ApiOperation({
summary: "Updates the username or display name of a logged user",
})
@ApiBearerAuth("JWT")
updateName(@Body() { displayName, username }: UpdateNameDTO, @Request() req) {
return this.userService.updateName(req.user as User, username, displayName);
}
@Patch("/email")
@ApiOperation({ summary: "Updates the email of a logged user" })
@ApiBearerAuth("JWT")
updateEmail(@Body() body: UpdateEmailDTO, @Request() req) {
return this.userService.updateEmail(req.user as User, body.email);
}
@Patch("/email")
@ApiOperation({ summary: "Updates the email of a logged user" })
@ApiBearerAuth("JWT")
updateEmail(@Body() body: UpdateEmailDTO, @Request() req) {
return this.userService.updateEmail(req.user as User, body.email);
}
@Patch("/password")
@ApiOperation({ summary: "Updates the password of a logged user" })
@ApiBearerAuth("JWT")
updatePassword(
@Body() { old_password, new_password }: UpdatePasswordDTO,
@Request() req,
) {
return this.userService.updatePassword(
req.user as User,
old_password,
new_password,
);
}
@Patch("/password")
@ApiOperation({ summary: "Updates the password of a logged user" })
@ApiBearerAuth("JWT")
updatePassword(
@Body() { old_password, new_password }: UpdatePasswordDTO,
@Request() req,
) {
return this.userService.updatePassword(
req.user as User,
old_password,
new_password,
);
}
@Patch("/image")
@ApiOperation({
summary: "Add a profile image",
})
@ApiBearerAuth("JWT")
@UseInterceptors(FileInterceptor("image"))
@ApiConsumes("multipart/form-data")
@ApiBody(UploadImageSchema)
uploadProfileImage(
@UploadedFile(
UploadImageValidator,
new BufferValidator(), // Magic number validation
)
image: File,
@Request() req,
) {
return this.userService.uploadImage(req.user, image);
}
@Patch("/image")
@ApiOperation({
summary: "Add a profile image",
})
@ApiBearerAuth("JWT")
@UseInterceptors(FileInterceptor("image"))
@ApiConsumes("multipart/form-data")
@ApiBody(UploadImageSchema)
uploadProfileImage(
@UploadedFile(
UploadImageValidator,
new BufferValidator(), // Magic number validation
)
image: File,
@Request() req,
) {
return this.userService.uploadImage(req.user, image);
}
// DELETE
@Delete()
@ApiOperation({ summary: "Deletes the account of a logged user" })
@ApiBearerAuth("JWT")
remove() {}
// DELETE
@Delete()
@ApiOperation({ summary: "Deletes the account of a logged user" })
@ApiBearerAuth("JWT")
remove() {}
}

View file

@ -1,13 +1,13 @@
import { Module } from "@nestjs/common";
import { UserController } from "./users.controller";
import { UserService } from "./users.service";
import { PrismaModule } from "src/prisma/prisma.module";
import { S3Service } from "./s3.service";
import { UserController } from "./users.controller";
import { UserService } from "./users.service";
@Module({
imports: [PrismaModule],
controllers: [UserController],
providers: [UserService, S3Service],
exports: [UserService],
imports: [PrismaModule],
controllers: [UserController],
providers: [UserService, S3Service],
exports: [UserService],
})
export class UserModule {}

View file

@ -1,221 +1,221 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { CreateUserDTO } from "./dto/create-user.dto";
import { PrismaService } from "src/prisma/prisma.service";
import { UserModel } from "./models/user.model";
import * as bcrypt from "bcrypt";
import { User } from "./types/user.type";
import { File } from "@nest-lab/fastify-multer";
import {
BadRequestException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import * as bcrypt from "bcrypt";
import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/create-user.dto";
import { UserModel } from "./models/user.model";
import { S3Service } from "./s3.service";
import { User } from "./types/user.type";
@Injectable()
export class UserService {
constructor(
private readonly prisma: PrismaService,
private readonly s3: S3Service,
) {}
async auth_search(username: string): Promise<UserModel> {
const user = await this.prisma.user.findFirst({
where: {
username,
},
select: {
id: true,
profileImage: true,
displayName: true,
username: true,
password: true,
},
});
constructor(
private readonly prisma: PrismaService,
private readonly s3: S3Service,
) {}
async auth_search(username: string): Promise<UserModel> {
const user = await this.prisma.user.findFirst({
where: {
username,
},
select: {
id: true,
profileImage: true,
displayName: true,
username: true,
password: true,
},
});
if (user == null) {
return undefined;
}
if (user == null) {
return undefined;
}
return user;
}
return user;
}
async info(username: string): Promise<UserModel> {
const user = await this.prisma.user.findFirst({
where: { username },
select: {
id: true,
profileImage: true,
displayName: true,
username: true,
createdAt: true,
followers: true,
following: true,
kweeks: {
select: {
id: true,
content: true,
createdAt: true,
updatedAt: true,
},
},
},
});
async info(username: string): Promise<UserModel> {
const user = await this.prisma.user.findFirst({
where: { username },
select: {
id: true,
profileImage: true,
displayName: true,
username: true,
createdAt: true,
followers: true,
following: true,
kweeks: {
select: {
id: true,
content: true,
createdAt: true,
updatedAt: true,
},
},
},
});
if (user === null) {
throw new NotFoundException("User not found");
}
if (user === null) {
throw new NotFoundException("User not found");
}
return {
...user,
followers: user.followers.length,
following: user.following.length,
};
}
return {
...user,
followers: user.followers.length,
following: user.following.length,
};
}
async create({
username,
email,
password,
}: CreateUserDTO): Promise<
Pick<UserModel, "displayName" | "username" | "createdAt">
> {
if ((await this.prisma.user.findFirst({ where: { username } })) != null) {
throw new BadRequestException("Username already in use");
}
async create({
username,
email,
password,
}: CreateUserDTO): Promise<
Pick<UserModel, "displayName" | "username" | "createdAt">
> {
if ((await this.prisma.user.findFirst({ where: { username } })) != null) {
throw new BadRequestException("Username already in use");
}
if ((await this.prisma.user.findFirst({ where: { email } })) != null) {
throw new BadRequestException("Email already in use");
}
if ((await this.prisma.user.findFirst({ where: { email } })) != null) {
throw new BadRequestException("Email already in use");
}
// Password encryption
const salt = await bcrypt.genSalt(15);
const hash = await bcrypt.hash(password, salt);
// Password encryption
const salt = await bcrypt.genSalt(15);
const hash = await bcrypt.hash(password, salt);
const user = await this.prisma.user.create({
data: {
username,
email,
password: hash,
},
select: {
displayName: true,
username: true,
createdAt: true,
},
});
const user = await this.prisma.user.create({
data: {
username,
email,
password: hash,
},
select: {
displayName: true,
username: true,
createdAt: true,
},
});
return user;
}
return user;
}
async updateEmail(
loggedUser: User,
email: string,
): Promise<{ message: string }> {
const user = await this.prisma.user.findFirst({
where: { id: loggedUser.id },
});
async updateEmail(
loggedUser: User,
email: string,
): Promise<{ message: string }> {
const user = await this.prisma.user.findFirst({
where: { id: loggedUser.id },
});
if (email !== undefined && email.trim() !== user.email) {
const isAlreadyInUse = await this.prisma.user.findFirst({
where: { email },
});
if (isAlreadyInUse != null && isAlreadyInUse.email !== user.email) {
throw new BadRequestException("Email already in use");
}
if (email !== undefined && email.trim() !== user.email) {
const isAlreadyInUse = await this.prisma.user.findFirst({
where: { email },
});
if (isAlreadyInUse != null && isAlreadyInUse.email !== user.email) {
throw new BadRequestException("Email already in use");
}
await this.prisma.user.update({
where: {
id: loggedUser.id,
},
data: {
email: email ?? user.email,
},
});
await this.prisma.user.update({
where: {
id: loggedUser.id,
},
data: {
email: email ?? user.email,
},
});
return { message: "Email updated successfully" };
}
}
return { message: "Email updated successfully" };
}
}
async updateName(
loggedUser: User,
username: string | undefined,
displayName: string,
): Promise<Pick<User, "username" | "displayName">> {
const user = await this.prisma.user.findFirst({
where: { id: loggedUser.id },
});
async updateName(
loggedUser: User,
username: string | undefined,
displayName: string,
): Promise<Pick<User, "username" | "displayName">> {
const user = await this.prisma.user.findFirst({
where: { id: loggedUser.id },
});
if (username !== undefined && username.trim() !== user.username) {
const isAlreadyInUse = await this.prisma.user.findFirst({
where: { username },
});
if (isAlreadyInUse != null && isAlreadyInUse.username !== user.username) {
throw new BadRequestException("Username already in use");
}
}
if (username !== undefined && username.trim() !== user.username) {
const isAlreadyInUse = await this.prisma.user.findFirst({
where: { username },
});
if (isAlreadyInUse != null && isAlreadyInUse.username !== user.username) {
throw new BadRequestException("Username already in use");
}
}
return await this.prisma.user.update({
where: {
id: loggedUser.id,
},
data: {
displayName,
username: username ?? user.username,
},
select: {
displayName: true,
username: true,
},
});
}
return await this.prisma.user.update({
where: {
id: loggedUser.id,
},
data: {
displayName,
username: username ?? user.username,
},
select: {
displayName: true,
username: true,
},
});
}
async updatePassword(
loggedUser: User,
old_password: string,
new_password: string,
): Promise<{ message: string }> {
const id = loggedUser.id;
async updatePassword(
loggedUser: User,
old_password: string,
new_password: string,
): Promise<{ message: string }> {
const id = loggedUser.id;
const user = await this.prisma.user.findFirst({
where: { id },
});
const user = await this.prisma.user.findFirst({
where: { id },
});
const validatePassword = await bcrypt.compare(old_password, user.password);
const validatePassword = await bcrypt.compare(old_password, user.password);
if (!validatePassword) {
throw new BadRequestException("Wrong password");
}
if (!validatePassword) {
throw new BadRequestException("Wrong password");
}
const salt = await bcrypt.genSalt(15);
const hash = await bcrypt.hash(new_password, salt);
const salt = await bcrypt.genSalt(15);
const hash = await bcrypt.hash(new_password, salt);
await this.prisma.user.update({
where: {
id,
},
data: {
password: hash,
},
});
await this.prisma.user.update({
where: {
id,
},
data: {
password: hash,
},
});
return { message: "Password updated successfully" };
}
return { message: "Password updated successfully" };
}
async uploadImage(authenticatedUser: User, image: File) {
const url = await this.s3.uploadImageToMinio(
authenticatedUser.id,
image.buffer,
);
async uploadImage(authenticatedUser: User, image: File) {
const url = await this.s3.uploadImageToMinio(
authenticatedUser.id,
image.buffer,
);
return await this.prisma.user.update({
where: {
id: authenticatedUser.id,
},
data: {
profileImage: url,
},
select: {
profileImage: true,
},
});
}
return await this.prisma.user.update({
where: {
id: authenticatedUser.id,
},
data: {
profileImage: url,
},
select: {
profileImage: true,
},
});
}
}

View file

@ -6,20 +6,20 @@ import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common";
*/
@Injectable()
export class BufferValidator implements PipeTransform {
async transform(value: File) {
const { fileTypeFromBuffer } = await (eval(
'import("file-type")',
) as Promise<typeof import("file-type")>);
async transform(value: File) {
const { fileTypeFromBuffer } = await (eval(
'import("file-type")',
) as Promise<typeof import("file-type")>);
const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp"];
const Type = await fileTypeFromBuffer(value.buffer);
const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp"];
const Type = await fileTypeFromBuffer(value.buffer);
if (!Type || !ALLOWED_MIMES.includes(Type.mime)) {
throw new BadRequestException(
"Invalid file type. Should be jpeg, png or webp",
);
}
if (!Type || !ALLOWED_MIMES.includes(Type.mime)) {
throw new BadRequestException(
"Invalid file type. Should be jpeg, png or webp",
);
}
return value;
}
return value;
}
}

View file

@ -1,17 +1,17 @@
import {
FileTypeValidator,
MaxFileSizeValidator,
ParseFilePipe,
FileTypeValidator,
MaxFileSizeValidator,
ParseFilePipe,
} from "@nestjs/common";
const UploadImageValidator = new ParseFilePipe({
validators: [
new MaxFileSizeValidator({
maxSize: 15 * 1024 * 1024,
message: "File too big. Max 1MB.",
}),
new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }), // File extension validation
],
validators: [
new MaxFileSizeValidator({
maxSize: 15 * 1024 * 1024,
message: "File too big. Max 1MB.",
}),
new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }), // File extension validation
],
});
export default UploadImageValidator;

View file

@ -1,24 +1,24 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from './../src/app.module';
import { INestApplication } from "@nestjs/common";
import { Test, TestingModule } from "@nestjs/testing";
import * as request from "supertest";
import { AppModule } from "./../src/app.module";
describe('AppController (e2e)', () => {
let app: INestApplication;
describe("AppController (e2e)", () => {
let app: INestApplication;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
app = moduleFixture.createNestApplication();
await app.init();
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});
it("/ (GET)", () => {
return request(app.getHttpServer())
.get("/")
.expect(200)
.expect("Hello World!");
});
});

View file

@ -1,9 +1,9 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
}
}

View file

@ -1,4 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
}