feat: added create kweek route & multi file upload

This commit is contained in:
Hackntosh 2024-02-05 18:08:32 +00:00
parent ac9026aea6
commit cab4a0d9f1
21 changed files with 225 additions and 103 deletions

View file

@ -3,6 +3,9 @@
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },
"files": {
"ignore": ["dist/**", "node_modules"]
},
"linter": { "linter": {
"enabled": true, "enabled": true,
"rules": { "rules": {

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Kweek" ADD COLUMN "attachments" TEXT[];

View file

@ -27,14 +27,15 @@ model User {
} }
model Kweek { model Kweek {
id String @id @default(uuid()) id String @id @default(uuid())
content String content String
authorId String authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade) author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
likes KweekLike[] likes KweekLike[]
comments Comments[] comments Comments[]
createdAt DateTime @default(now()) attachments String[]
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
} }
model KweekLike { model KweekLike {

View file

@ -12,7 +12,7 @@ import {
ApiTags, ApiTags,
ApiUnauthorizedResponse, ApiUnauthorizedResponse,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Public } from "src/public.decorator"; import { Public } from "src/decorators/public.decorator";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { LoginUserDTO } from "./dto/login.dto"; import { LoginUserDTO } from "./dto/login.dto";
import { LocalAuthGuard } from "./local-auth.guard"; import { LocalAuthGuard } from "./local-auth.guard";

View file

@ -2,7 +2,7 @@ import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core"; import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport"; import { AuthGuard } from "@nestjs/passport";
import { Observable } from "rxjs"; import { Observable } from "rxjs";
import { IS_PUBLIC_KEY } from "src/public.decorator"; import { IS_PUBLIC_KEY } from "src/decorators/public.decorator";
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") { export class JwtAuthGuard extends AuthGuard("jwt") {

View file

@ -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);
};

View file

@ -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) {}

View file

@ -1,4 +1,4 @@
import { FilesInterceptor } from "@nest-lab/fastify-multer"; import { File, FilesInterceptor } from "@nest-lab/fastify-multer";
import { import {
Body, Body,
Controller, Controller,
@ -17,8 +17,9 @@ import {
ApiOperation, ApiOperation,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Public } from "src/public.decorator"; import { ApiCreateKweek } from "src/decorators/create-kweek.decorator";
import { CreateKweekDTO } from "./dto/create-kweek.dto"; import { Public } from "src/decorators/public.decorator";
import { MultiFileValidation } from "src/validators/multi-file.validator";
import { UpdateKweekDTO } from "./dto/update-kweek.dto"; import { UpdateKweekDTO } from "./dto/update-kweek.dto";
import { KweeksService } from "./kweeks.service"; import { KweeksService } from "./kweeks.service";
@ -28,17 +29,17 @@ export class KweeksController {
constructor(private readonly kweeksService: KweeksService) {} constructor(private readonly kweeksService: KweeksService) {}
@Post() @Post()
@ApiConsumes("multipart/form-data")
@ApiCreateKweek("attachments")
@UseInterceptors(FilesInterceptor("attachments", 4))
@ApiOperation({ summary: "Creates a kweek" }) @ApiOperation({ summary: "Creates a kweek" })
@ApiBearerAuth("JWT") @ApiBearerAuth("JWT")
@ApiConsumes("multipart/form-data")
@UseInterceptors(FilesInterceptor("attachments", 4))
create( create(
@Body() createKweekDto: CreateKweekDTO, @UploadedFiles(new MultiFileValidation()) attachments: Array<File>,
@UploadedFiles() attachments: File, @Body() body,
@Request() req, @Request() req,
) { ) {
// TODO: Find a way to handle multiple files with Swagger return this.kweeksService.create(body.content, req.user.id, attachments);
return this.kweeksService.create(createKweekDto);
} }
@Public() @Public()
@ -51,7 +52,7 @@ export class KweeksController {
@Patch(":id") @Patch(":id")
@ApiOperation({ summary: "Updates a kweek content" }) @ApiOperation({ summary: "Updates a kweek content" })
@ApiBearerAuth("JWT") @ApiBearerAuth("JWT")
update(@Param("id") id: string, @Body() updateKweekDto: UpdateKweekDTO ) { update(@Param("id") id: string, @Body() updateKweekDto: UpdateKweekDTO) {
return this.kweeksService.update(+id, updateKweekDto); return this.kweeksService.update(+id, updateKweekDto);
} }

View file

@ -1,11 +1,12 @@
import { Module } from "@nestjs/common"; 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 { KweeksController } from "./kweeks.controller";
import { KweeksService } from "./kweeks.service"; import { KweeksService } from "./kweeks.service";
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule],
controllers: [KweeksController], controllers: [KweeksController],
providers: [KweeksService], providers: [KweeksService, S3Service],
}) })
export class KweeksModule {} export class KweeksModule {}

View file

@ -1,13 +1,35 @@
import { File } from "@nest-lab/fastify-multer";
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/services/prisma/prisma.service";
import { CreateKweekDTO } from "./dto/create-kweek.dto"; import { S3Service } from "src/services/s3/s3.service";
import { UpdateKweekDTO } from "./dto/update-kweek.dto"; import { UpdateKweekDTO } from "./dto/update-kweek.dto";
@Injectable() @Injectable()
export class KweeksService { export class KweeksService {
constructor(private readonly prisma: PrismaService) {} constructor(
create(createKweekDto: CreateKweekDTO) { private readonly prisma: PrismaService,
return "This action adds a new kweek"; private readonly s3: S3Service,
) {}
async create(content: string, authorId: string, files: Array<File>) {
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) { findOne(id: number) {

View file

@ -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<string> {
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<File>) {
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}`
}
}

View file

@ -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<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 { 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",
);
}
}

View file

@ -23,8 +23,8 @@ import {
ApiTags, ApiTags,
ApiUnauthorizedResponse, ApiUnauthorizedResponse,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Public } from "src/public.decorator"; import { Public } from "src/decorators/public.decorator";
import { BufferValidator } from "src/validators/buffer-validator.pipe"; import { BufferValidator } from "src/validators/buffer.validator";
import UploadImageValidator from "src/validators/upload-image.validator"; import UploadImageValidator from "src/validators/upload-image.validator";
import { CreateUserDTO } from "./dto/create-user.dto"; import { CreateUserDTO } from "./dto/create-user.dto";
import { UpdateEmailDTO } from "./dto/update-email.dto"; import { UpdateEmailDTO } from "./dto/update-email.dto";

View file

@ -1,6 +1,6 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PrismaModule } from "src/prisma/prisma.module"; import { PrismaModule } from "src/services/prisma/prisma.module";
import { S3Service } from "./s3.service"; import { S3Service } from "src/services/s3/s3.service";
import { UserController } from "./users.controller"; import { UserController } from "./users.controller";
import { UserService } from "./users.service"; import { UserService } from "./users.service";

View file

@ -5,10 +5,10 @@ import {
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import * as bcrypt from "bcrypt"; 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 { CreateUserDTO } from "./dto/create-user.dto";
import { UserModel } from "./models/user.model"; import { UserModel } from "./models/user.model";
import { S3Service } from "./s3.service";
import { User } from "./types/user.type"; import { User } from "./types/user.type";
@Injectable() @Injectable()
@ -53,6 +53,7 @@ export class UserService {
select: { select: {
id: true, id: true,
content: true, content: true,
attachments: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
@ -106,10 +107,7 @@ export class UserService {
return user; return user;
} }
async updateEmail( async updateEmail(id: string, email: string): Promise<{ message: string }> {
id: string,
email: string,
): Promise<{ message: string }> {
const user = await this.prisma.user.findFirst({ const user = await this.prisma.user.findFirst({
where: { id }, where: { id },
}); });
@ -155,7 +153,7 @@ export class UserService {
return await this.prisma.user.update({ return await this.prisma.user.update({
where: { where: {
id id,
}, },
data: { data: {
displayName, displayName,
@ -199,10 +197,7 @@ export class UserService {
} }
async uploadImage(id: string, image: File) { async uploadImage(id: string, image: File) {
const url = await this.s3.uploadImageToMinio( const url = await this.s3.uploadImageToMinio(id, image.buffer);
id,
image.buffer,
);
return await this.prisma.user.update({ return await this.prisma.user.update({
where: { where: {
@ -217,13 +212,13 @@ export class UserService {
}); });
} }
async delete(id: string) { async delete(id: string) {
// TODO: Add validation for safety (like e-mail confirmation or password) // TODO: Add validation for safety (like e-mail confirmation or password)
try { try {
await this.prisma.user.deleteMany({where: {id}});; await this.prisma.user.deleteMany({ where: { id } });
return { message: "User deleted"} return { message: "User deleted" };
} catch (e) { } catch (e) {
throw new BadRequestException('Error while trying to delete user') throw new BadRequestException("Error while trying to delete user");
} }
} }
} }

View file

@ -12,9 +12,9 @@ export class BufferValidator implements PipeTransform {
) as Promise<typeof import("file-type")>); ) as Promise<typeof import("file-type")>);
const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp"]; 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( throw new BadRequestException(
"Invalid file type. Should be jpeg, png or webp", "Invalid file type. Should be jpeg, png or webp",
); );

View file

@ -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<File>) {
const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp"];
const errors = [];
const { fileTypeFromBuffer } = await (eval(
'import("file-type")',
) as Promise<typeof import("file-type")>);
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);
}
}

View file

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