diff --git a/biome.json b/biome.json index 2003fa2..3997749 100644 --- a/biome.json +++ b/biome.json @@ -3,6 +3,9 @@ "organizeImports": { "enabled": true }, + "files": { + "ignore": ["dist/**", "node_modules"] + }, "linter": { "enabled": true, "rules": { diff --git a/prisma/migrations/20240205160029_added_attachment_kweek_field/migration.sql b/prisma/migrations/20240205160029_added_attachment_kweek_field/migration.sql new file mode 100644 index 0000000..343c4dc --- /dev/null +++ b/prisma/migrations/20240205160029_added_attachment_kweek_field/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Kweek" ADD COLUMN "attachments" TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b3bbba1..53c405a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -27,14 +27,15 @@ model User { } model Kweek { - id String @id @default(uuid()) - content String - authorId String - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) - likes KweekLike[] - comments Comments[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(uuid()) + content String + authorId String + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + likes KweekLike[] + comments Comments[] + attachments String[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model KweekLike { diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index cf9ef27..71c0f80 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -12,7 +12,7 @@ import { ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { Public } from "src/public.decorator"; +import { Public } from "src/decorators/public.decorator"; import { AuthService } from "./auth.service"; import { LoginUserDTO } from "./dto/login.dto"; import { LocalAuthGuard } from "./local-auth.guard"; diff --git a/src/auth/jwt-auth.guard.ts b/src/auth/jwt-auth.guard.ts index 54ef454..dd7d59c 100644 --- a/src/auth/jwt-auth.guard.ts +++ b/src/auth/jwt-auth.guard.ts @@ -2,7 +2,7 @@ import { ExecutionContext, Injectable } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { AuthGuard } from "@nestjs/passport"; import { Observable } from "rxjs"; -import { IS_PUBLIC_KEY } from "src/public.decorator"; +import { IS_PUBLIC_KEY } from "src/decorators/public.decorator"; @Injectable() export class JwtAuthGuard extends AuthGuard("jwt") { diff --git a/src/decorators/create-kweek.decorator.ts b/src/decorators/create-kweek.decorator.ts new file mode 100644 index 0000000..266e30e --- /dev/null +++ b/src/decorators/create-kweek.decorator.ts @@ -0,0 +1,27 @@ +// Thanks sandeepsuvit @ https://github.com/nestjs/swagger/issues/417 + +import { ApiBody } from "@nestjs/swagger"; + +export const ApiCreateKweek = + (fieldName: string): MethodDecorator => + // biome-ignore lint/suspicious/noExplicitAny: idk typing for target + (target: any, propertyKey: string, descriptor: PropertyDescriptor) => { + ApiBody({ + required: true, + schema: { + type: "object", + properties: { + content: { + type: "string", + }, + [fieldName]: { + type: "array", + items: { + type: "string", + format: "binary", + }, + }, + }, + }, + })(target, propertyKey, descriptor); + }; diff --git a/src/public.decorator.ts b/src/decorators/public.decorator.ts similarity index 100% rename from src/public.decorator.ts rename to src/decorators/public.decorator.ts diff --git a/src/kweeks/dto/create-kweek.dto.ts b/src/kweeks/dto/create-kweek.dto.ts deleted file mode 100644 index 87008c8..0000000 --- a/src/kweeks/dto/create-kweek.dto.ts +++ /dev/null @@ -1,11 +0,0 @@ -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(); - -export class CreateKweekDTO extends createZodDto(CreateKweekSchema) {} diff --git a/src/kweeks/kweeks.controller.ts b/src/kweeks/kweeks.controller.ts index 01ccd22..75fbe3a 100644 --- a/src/kweeks/kweeks.controller.ts +++ b/src/kweeks/kweeks.controller.ts @@ -1,4 +1,4 @@ -import { FilesInterceptor } from "@nest-lab/fastify-multer"; +import { File, FilesInterceptor } from "@nest-lab/fastify-multer"; import { Body, Controller, @@ -17,8 +17,9 @@ import { ApiOperation, ApiTags, } from "@nestjs/swagger"; -import { Public } from "src/public.decorator"; -import { CreateKweekDTO } from "./dto/create-kweek.dto"; +import { ApiCreateKweek } from "src/decorators/create-kweek.decorator"; +import { Public } from "src/decorators/public.decorator"; +import { MultiFileValidation } from "src/validators/multi-file.validator"; import { UpdateKweekDTO } from "./dto/update-kweek.dto"; import { KweeksService } from "./kweeks.service"; @@ -28,17 +29,17 @@ export class KweeksController { constructor(private readonly kweeksService: KweeksService) {} @Post() + @ApiConsumes("multipart/form-data") + @ApiCreateKweek("attachments") + @UseInterceptors(FilesInterceptor("attachments", 4)) @ApiOperation({ summary: "Creates a kweek" }) @ApiBearerAuth("JWT") - @ApiConsumes("multipart/form-data") - @UseInterceptors(FilesInterceptor("attachments", 4)) create( - @Body() createKweekDto: CreateKweekDTO, - @UploadedFiles() attachments: File, + @UploadedFiles(new MultiFileValidation()) attachments: Array, + @Body() body, @Request() req, ) { - // TODO: Find a way to handle multiple files with Swagger - return this.kweeksService.create(createKweekDto); + return this.kweeksService.create(body.content, req.user.id, attachments); } @Public() @@ -51,7 +52,7 @@ export class KweeksController { @Patch(":id") @ApiOperation({ summary: "Updates a kweek content" }) @ApiBearerAuth("JWT") - update(@Param("id") id: string, @Body() updateKweekDto: UpdateKweekDTO ) { + update(@Param("id") id: string, @Body() updateKweekDto: UpdateKweekDTO) { return this.kweeksService.update(+id, updateKweekDto); } diff --git a/src/kweeks/kweeks.module.ts b/src/kweeks/kweeks.module.ts index a2213d9..891235b 100644 --- a/src/kweeks/kweeks.module.ts +++ b/src/kweeks/kweeks.module.ts @@ -1,11 +1,12 @@ import { Module } from "@nestjs/common"; -import { PrismaModule } from "src/prisma/prisma.module"; +import { PrismaModule } from "src/services/prisma/prisma.module"; +import { S3Service } from "src/services/s3/s3.service"; import { KweeksController } from "./kweeks.controller"; import { KweeksService } from "./kweeks.service"; @Module({ imports: [PrismaModule], controllers: [KweeksController], - providers: [KweeksService], + providers: [KweeksService, S3Service], }) export class KweeksModule {} diff --git a/src/kweeks/kweeks.service.ts b/src/kweeks/kweeks.service.ts index e4175f1..117d3e4 100644 --- a/src/kweeks/kweeks.service.ts +++ b/src/kweeks/kweeks.service.ts @@ -1,13 +1,35 @@ +import { File } from "@nest-lab/fastify-multer"; import { Injectable } from "@nestjs/common"; -import { PrismaService } from "src/prisma/prisma.service"; -import { CreateKweekDTO } from "./dto/create-kweek.dto"; +import { PrismaService } from "src/services/prisma/prisma.service"; +import { S3Service } from "src/services/s3/s3.service"; import { UpdateKweekDTO } from "./dto/update-kweek.dto"; @Injectable() export class KweeksService { - constructor(private readonly prisma: PrismaService) {} - create(createKweekDto: CreateKweekDTO) { - return "This action adds a new kweek"; + constructor( + private readonly prisma: PrismaService, + private readonly s3: S3Service, + ) {} + async create(content: string, authorId: string, files: Array) { + const post = await this.prisma.kweek.create({ + data: { + content, + authorId, + } + }); + + const attachments = await this.s3.multiImageUploadToMinio(post.id, files); + + await this.prisma.kweek.updateMany({ + where: { + id: post.id + }, + data: { + attachments + }, + }); + + return await this.prisma.kweek.findUnique({where: {id: post.id}}); } findOne(id: number) { diff --git a/src/prisma/prisma.module.ts b/src/services/prisma/prisma.module.ts similarity index 100% rename from src/prisma/prisma.module.ts rename to src/services/prisma/prisma.module.ts diff --git a/src/prisma/prisma.service.ts b/src/services/prisma/prisma.service.ts similarity index 100% rename from src/prisma/prisma.service.ts rename to src/services/prisma/prisma.service.ts diff --git a/src/services/s3/s3.service.ts b/src/services/s3/s3.service.ts new file mode 100644 index 0000000..000c41f --- /dev/null +++ b/src/services/s3/s3.service.ts @@ -0,0 +1,74 @@ +import { PutObjectCommand, PutObjectCommandInput } from "@aws-sdk/client-s3"; +import { BadRequestException, Injectable, InternalServerErrorException } from "@nestjs/common"; +import { InjectS3, S3 } from "nestjs-s3"; +import { File } from "@nest-lab/fastify-multer"; +import sharp from "sharp"; + +@Injectable() +export class S3Service { + constructor(@InjectS3() private readonly s3: S3) {} + + /** + * Returns the image url if the upload to minio was successful. + */ + async uploadImageToMinio(userID: string, buffer: Buffer): Promise { + const compressedBuffer = await sharp(buffer) + .resize(200, 200) + .webp({ quality: 70 }) + .toBuffer(); + + const params: 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(params)); + + 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", + ); + } + + async multiImageUploadToMinio(id: string, files: Array) { + const buffers: Buffer[] = []; + + if (files.length === 0) { + return []; + } + + for (let i = 0; i < files.length; i++) { + const { buffer } = files[i]; + + const compressedBuffers = await sharp(buffer).webp({quality: 70}).toBuffer(); + buffers.push(compressedBuffers); + } + + const uploadPromises = buffers.map(async (buffer, index) => { + return await this.multiUploadToMinio(buffer, id, index + 1); + }); + + return Promise.all(uploadPromises); + } + + private async multiUploadToMinio(buffer: Buffer, id: string, index: number) { + const Key = `posts/${id}/${index}.webp`; + + const params: PutObjectCommandInput = { + Bucket: process.env.MINIO_DEFAULT_BUCKETS, + Key, + Body: buffer, + ContentType: 'image/webp', + }; + + await this.s3.send(new PutObjectCommand(params)) + return `${process.env.MINIO_ENDPOINT}/${process.env.MINIO_DEFAULT_BUCKETS}/${Key}` + } +} diff --git a/src/users/s3.service.ts b/src/users/s3.service.ts deleted file mode 100644 index 01fd281..0000000 --- a/src/users/s3.service.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { PutObjectCommand, PutObjectCommandInput } from "@aws-sdk/client-s3"; -import { Injectable, InternalServerErrorException } from "@nestjs/common"; -import { InjectS3, S3 } from "nestjs-s3"; -import sharp from "sharp"; - -@Injectable() -export class S3Service { - constructor(@InjectS3() private readonly s3: S3) {} - - /** - * Returns the image url if the upload to minio was successful. - */ - async uploadImageToMinio(userID: string, buffer: Buffer): Promise { - 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 { 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`; - } - - throw new InternalServerErrorException( - "Failed to upload the profile image", - ); - } -} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 3e44ff0..fafd60a 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -23,8 +23,8 @@ import { ApiTags, ApiUnauthorizedResponse, } from "@nestjs/swagger"; -import { Public } from "src/public.decorator"; -import { BufferValidator } from "src/validators/buffer-validator.pipe"; +import { Public } from "src/decorators/public.decorator"; +import { BufferValidator } from "src/validators/buffer.validator"; import UploadImageValidator from "src/validators/upload-image.validator"; import { CreateUserDTO } from "./dto/create-user.dto"; import { UpdateEmailDTO } from "./dto/update-email.dto"; diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 72a2381..8dac932 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,6 +1,6 @@ import { Module } from "@nestjs/common"; -import { PrismaModule } from "src/prisma/prisma.module"; -import { S3Service } from "./s3.service"; +import { PrismaModule } from "src/services/prisma/prisma.module"; +import { S3Service } from "src/services/s3/s3.service"; import { UserController } from "./users.controller"; import { UserService } from "./users.service"; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index f5e8c00..3548ce2 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -5,10 +5,10 @@ import { NotFoundException, } from "@nestjs/common"; import * as bcrypt from "bcrypt"; -import { PrismaService } from "src/prisma/prisma.service"; +import { PrismaService } from "src/services/prisma/prisma.service"; +import { S3Service } from "src/services/s3/s3.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() @@ -53,6 +53,7 @@ export class UserService { select: { id: true, content: true, + attachments: true, createdAt: true, updatedAt: true, }, @@ -106,10 +107,7 @@ export class UserService { return user; } - async updateEmail( - id: string, - email: string, - ): Promise<{ message: string }> { + async updateEmail(id: string, email: string): Promise<{ message: string }> { const user = await this.prisma.user.findFirst({ where: { id }, }); @@ -155,7 +153,7 @@ export class UserService { return await this.prisma.user.update({ where: { - id + id, }, data: { displayName, @@ -199,10 +197,7 @@ export class UserService { } async uploadImage(id: string, image: File) { - const url = await this.s3.uploadImageToMinio( - id, - image.buffer, - ); + const url = await this.s3.uploadImageToMinio(id, image.buffer); return await this.prisma.user.update({ where: { @@ -217,13 +212,13 @@ export class UserService { }); } - async delete(id: string) { - // TODO: Add validation for safety (like e-mail confirmation or password) - try { - await this.prisma.user.deleteMany({where: {id}});; - return { message: "User deleted"} - } catch (e) { - throw new BadRequestException('Error while trying to delete user') - } - } + async delete(id: string) { + // TODO: Add validation for safety (like e-mail confirmation or password) + try { + await this.prisma.user.deleteMany({ where: { id } }); + return { message: "User deleted" }; + } catch (e) { + throw new BadRequestException("Error while trying to delete user"); + } + } } diff --git a/src/validators/buffer-validator.pipe.ts b/src/validators/buffer.validator.ts similarity index 82% rename from src/validators/buffer-validator.pipe.ts rename to src/validators/buffer.validator.ts index 589194d..f3460bc 100644 --- a/src/validators/buffer-validator.pipe.ts +++ b/src/validators/buffer.validator.ts @@ -12,9 +12,9 @@ export class BufferValidator implements PipeTransform { ) as Promise); const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp"]; - const Type = await fileTypeFromBuffer(value.buffer); + const buffer_type = await fileTypeFromBuffer(value.buffer); - if (!Type || !ALLOWED_MIMES.includes(Type.mime)) { + if (!buffer_type || !ALLOWED_MIMES.includes(buffer_type.mime)) { throw new BadRequestException( "Invalid file type. Should be jpeg, png or webp", ); diff --git a/src/validators/multi-file.validator.ts b/src/validators/multi-file.validator.ts new file mode 100644 index 0000000..62f6988 --- /dev/null +++ b/src/validators/multi-file.validator.ts @@ -0,0 +1,45 @@ +import { File } from "@nest-lab/fastify-multer"; +import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common"; + +@Injectable() +export class MultiFileValidation implements PipeTransform { + async transform(value: Array) { + const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp"]; + + const errors = []; + + const { fileTypeFromBuffer } = await (eval( + 'import("file-type")', + ) as Promise); + + for (let i = 0; i < value.length; i++) { + const file = value[i]; + const { buffer, size } = file; + + try { + const buffer_type = await fileTypeFromBuffer(buffer); + + if (!buffer_type || !ALLOWED_MIMES.includes(buffer_type.mime)) { + errors.push({ + file: i + 1, + error: "Invalid file type. Should be jpeg, png or webp", + }); + } + + const maxFileSize = 1024 * 1024; + + if (size > maxFileSize) { + errors.push({ file: i + 1, error: "File is too big. Max 1MB" }); + } + } catch (e) { + errors.push({ file: i + 1, error: e.message }); + } + } + + if (errors.length === 0) { + return value; + } + + throw new BadRequestException(errors); + } +} diff --git a/src/validators/upload-image.validator.ts b/src/validators/upload-image.validator.ts index b3b9c3f..29cb6c9 100644 --- a/src/validators/upload-image.validator.ts +++ b/src/validators/upload-image.validator.ts @@ -7,7 +7,7 @@ import { const UploadImageValidator = new ParseFilePipe({ validators: [ new MaxFileSizeValidator({ - maxSize: 15 * 1024 * 1024, + maxSize: 1024 * 1024, message: "File too big. Max 1MB.", }), new FileTypeValidator({ fileType: /^image\/(jpeg|png|webp)$/ }), // File extension validation