feat: finished routes, renamed a few files and functions

This commit is contained in:
Hackntosh 2024-10-12 17:36:41 +01:00
parent af7745070e
commit 8a55a1938c
38 changed files with 678 additions and 15350 deletions

View file

@ -10,12 +10,14 @@ A simple RESTful API made with **NestJS** and **Fastify**.
### 🚀 Preparing the environment ### 🚀 Preparing the environment
Make sure that you have Node, NPM, Docker and Docker Compose installed on your computer. Make sure that you have Bun, Docker and Docker Compose installed on your computer.
This project also works with Node and Npm.
First, install the necessary packages with the following commands: First, install the necessary packages with the following commands:
```bash ```bash
$ npm i bun i
``` ```
After that, you can update the `.env` and the `docker.env` files. The `.env` file is for development environment and the `docker.env` is for production. After that, you can update the `.env` and the `docker.env` files. The `.env` file is for development environment and the `docker.env` is for production.
@ -25,7 +27,7 @@ You can find the templates for those files on `.env.example` and `docker.env.exa
To run the necessary services you can execute the following command: To run the necessary services you can execute the following command:
```bash ```bash
$ npm run docker:db bun docker:db
``` ```
This will start the following services: This will start the following services:
@ -37,13 +39,13 @@ This will start the following services:
Apply the migrations to the database with the following command: Apply the migrations to the database with the following command:
```bash ```bash
$ npm run migrate:dev bun migrate:dev
``` ```
And now, you can start the server with the command: And now, you can start the server with the command:
```bash ```bash
$ npm run dev:start bun dev:start
``` ```
You can check the documentation accessing the endpoint `/` in your browser You can check the documentation accessing the endpoint `/` in your browser
@ -51,7 +53,7 @@ You can check the documentation accessing the endpoint `/` in your browser
To run in production you can use the following command: To run in production you can use the following command:
```bash ```bash
$ npm run docker bun docker
``` ```
This will start all the previous services and the back-end image. This will start all the previous services and the back-end image.
@ -60,11 +62,11 @@ This will start all the previous services and the back-end image.
This back-end uses the following stack: This back-end uses the following stack:
- **Fastify**
- **MinIO**
- **NestJS** - **NestJS**
- **PostgreSQL** - **Fastify**
- **Prisma** - **Prisma**
- **MinIO**
- **PostgreSQL**
- **Redis** - **Redis**
- **Swagger** - **Swagger**
- **Typescript** - **Typescript**

BIN
bun.lockb Executable file

Binary file not shown.

View file

@ -1,5 +1,3 @@
version: '3.8'
networks: networks:
localstack-net: localstack-net:
name: localstack-net name: localstack-net

View file

@ -1,5 +1,3 @@
version: '3.8'
networks: networks:
localstack-net: localstack-net:
name: localstack-net name: localstack-net

15114
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -28,63 +28,62 @@
"test:watch": "jest --watch" "test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.502.0", "@aws-sdk/client-s3": "^3.664.0",
"@fastify/helmet": "^11.1.1", "@fastify/helmet": "^11.1.1",
"@fastify/multipart": "^8.1.0", "@fastify/multipart": "^8.3.0",
"@fastify/static": "^6.12.0", "@fastify/static": "^6.12.0",
"@nest-lab/fastify-multer": "^1.2.0", "@nest-lab/fastify-multer": "^1.2.0",
"@nestjs/common": "^10.0.0", "@nest-lab/throttler-storage-redis": "^1.0.0",
"@nestjs/config": "^3.1.1", "@nestjs/common": "^10.4.4",
"@nestjs/core": "^10.0.0", "@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.4",
"@nestjs/jwt": "^10.2.0", "@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3", "@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.1", "@nestjs/platform-fastify": "^10.4.4",
"@nestjs/swagger": "^7.2.0", "@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^5.1.1", "@nestjs/throttler": "6.2.1",
"@prisma/client": "^5.9.1", "@prisma/client": "^5.20.0",
"bcrypt": "^5.1.1", "argon2": "^0.41.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6", "dotenv-expand": "^11.0.6",
"file-type": "^19.0.0", "file-type": "^19.5.0",
"ioredis": "^5.3.2", "ioredis": "^5.4.1",
"nestjs-s3": "^2.0.1", "nestjs-s3": "^2.0.1",
"nestjs-throttler-storage-redis": "^0.4.1",
"nestjs-zod": "^3.0.0", "nestjs-zod": "^3.0.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"sharp": "^0.33.2", "sharp": "^0.33.5",
"tstl": "^3.0.0" "tstl": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "1.5.3", "@biomejs/biome": "1.5.3",
"@commitlint/cli": "^18.6.0", "@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.0", "@commitlint/config-conventional": "^18.6.3",
"@nestjs/cli": "^10.3.2", "@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.0.0", "@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.3.3", "@nestjs/testing": "^10.4.4",
"@swc/cli": "^0.1.65", "@swc/cli": "^0.1.65",
"@swc/core": "^1.3.107", "@swc/core": "^1.7.26",
"@swc/jest": "^0.2.36", "@swc/jest": "^0.2.36",
"@types/bcrypt": "^5.0.2", "@types/jest": "^29.5.13",
"@types/jest": "^29.5.2", "@types/node": "^20.16.10",
"@types/node": "^20.12.7", "@types/passport-jwt": "^4.0.1",
"@types/passport-jwt": "^4.0.0",
"@types/passport-local": "^1.0.38", "@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.0", "@types/supertest": "^6.0.2",
"husky": "^9.0.7", "husky": "^9.1.6",
"jest": "^29.5.0", "jest": "^29.7.0",
"lint-staged": "^15.2.1", "lint-staged": "^15.2.10",
"prisma": "^5.12.1", "prisma": "^5.20.0",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^6.3.3", "supertest": "^6.3.4",
"ts-jest": "^29.1.0", "ts-jest": "^29.2.5",
"ts-loader": "^9.4.3", "ts-loader": "^9.5.1",
"ts-node": "^10.9.1", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3" "typescript": "^5.6.2"
}, },
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
@ -107,5 +106,9 @@
}, },
"lint-staged": { "lint-staged": {
"**/*.@(js|ts|json)": "biome check --apply --no-errors-on-unmatched" "**/*.@(js|ts|json)": "biome check --apply --no-errors-on-unmatched"
} },
"trustedDependencies": [
"@biomejs/biome",
"@nestjs/core"
]
} }

View file

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

View file

@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "Comments" ADD COLUMN "parentId" TEXT,
ALTER COLUMN "kweekId" DROP NOT NULL;
-- AddForeignKey
ALTER TABLE "Comments" ADD CONSTRAINT "Comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Comments"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View file

@ -68,15 +68,20 @@ model Follows {
} }
model Comments { model Comments {
id String @id @default(uuid()) id String @id @default(uuid())
content String content String
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kweekId String kweekId String?
kweek Kweek @relation(fields: [kweekId], references: [id], onDelete: Cascade) kweek Kweek? @relation(fields: [kweekId], references: [id], onDelete: Cascade)
likes CommentLike[] likes CommentLike[]
createdAt DateTime @default(now()) attachments String[]
updatedAt DateTime @updatedAt @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
parentId String?
parent Comments? @relation("CommentReplies", fields: [parentId], references: [id])
replies Comments[] @relation("CommentReplies")
} }
model Notifications { model Notifications {

View file

@ -1,10 +1,10 @@
import { FastifyMulterModule } from "@nest-lab/fastify-multer"; import { FastifyMulterModule } from "@nest-lab/fastify-multer";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_PIPE } from "@nestjs/core"; import { APP_GUARD, APP_PIPE } from "@nestjs/core";
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"; import { ThrottlerGuard, ThrottlerModule, seconds } from "@nestjs/throttler";
import { S3Module } from "nestjs-s3"; import { S3Module } from "nestjs-s3";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { ZodValidationPipe } from "nestjs-zod"; import { ZodValidationPipe } from "nestjs-zod";
import { AuthModule } from "./auth/auth.module"; import { AuthModule } from "./auth/auth.module";
import { JwtAuthGuard } from "./auth/jwt-auth.guard"; import { JwtAuthGuard } from "./auth/jwt-auth.guard";
@ -14,17 +14,18 @@ import { UserModule } from "./users/users.module";
@Module({ @Module({
imports: [ imports: [
UserModule, ThrottlerModule.forRoot({
AuthModule, throttlers: [{ limit: 10, ttl: seconds(60) }],
storage: new ThrottlerStorageRedisService(Configuration.REDIS_URL()),
errorMessage: "Too many requests",
}),
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
}), }),
ThrottlerModule.forRoot({
throttlers: [{ limit: 10, ttl: 60000 }],
storage: new ThrottlerStorageRedisService(Configuration.REDIS_URL()),
}),
KweeksModule,
FastifyMulterModule, FastifyMulterModule,
UserModule,
KweeksModule,
AuthModule,
S3Module.forRoot({ S3Module.forRoot({
config: { config: {
credentials: { credentials: {
@ -38,14 +39,14 @@ import { UserModule } from "./users/users.module";
}), }),
], ],
providers: [ providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: ThrottlerGuard, useClass: ThrottlerGuard,
}, },
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{ {
provide: APP_GUARD, provide: APP_GUARD,
useClass: JwtAuthGuard, useClass: JwtAuthGuard,

View file

@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt"; import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt"; import * as argon2 from "argon2";
import { UserModel } from "src/users/models/user.model"; import { UserModel } from "src/users/models/user.model";
import { UserService } from "src/users/users.service"; import { UserService } from "src/users/users.service";
@ -21,7 +21,7 @@ export class AuthService {
return null; return null;
} }
const validation = await bcrypt.compare(password, user.password); const validation = await argon2.verify(user.password, password);
if (user && validation) { if (user && validation) {
const { password, ...result } = user; const { password, ...result } = user;

View file

@ -1,28 +0,0 @@
// Thanks sandeepsuvit @ https://github.com/nestjs/swagger/issues/417
// TODO: Remove this decorator.
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

@ -0,0 +1,85 @@
import { File, FilesInterceptor } from "@nest-lab/fastify-multer";
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Request,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import { Public } from "src/decorators/public.decorator";
import { MultiFileValidation } from "src/validators/multi_file.validator";
import { CommentsService } from "./comments.service";
import { UpdateCommentDTO } from "./dto/comments/update_comment.dto";
import { AttachmentsSchema } from "./schemas/attachments.schema";
@ApiTags("Kweeks")
@Controller("kweeks")
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Post(":id/comments")
@ApiConsumes("multipart/form-data")
@ApiBody(AttachmentsSchema)
@UseInterceptors(FilesInterceptor("attachments", 4))
@ApiOperation({ summary: "Creates a comment" })
@ApiBearerAuth("JWT")
@ApiBadRequestResponse({ description: "Content too long" })
create(
@UploadedFiles(new MultiFileValidation()) attachments: Array<File>,
@Body() body,
@Request() req,
@Param("id") id: string,
) {
return this.commentsService.create(
id,
body.content,
req.user.id,
attachments,
);
}
@Public()
@Get("comments/:comment_id")
@ApiOperation({ summary: "Retrieves information about a comment" })
comment(@Param("comment_id") comment_id: string) {
return this.commentsService.info(comment_id);
}
@Patch(":id/comments/:comment_id")
@ApiOperation({ summary: "Updates a comment content" })
@ApiBearerAuth("JWT")
updateComment(
@Param("comment_id") comment_id: string,
@Request() req,
@Body() body: UpdateCommentDTO,
) {
return this.commentsService.update(comment_id, req.user.id, body.content);
}
@Delete("comments/:comment_id")
@ApiOperation({ summary: "Deletes a comment" })
@ApiBearerAuth("JWT")
removeComment(@Param("comment_id") comment_id: string, @Request() req) {
return this.commentsService.delete(comment_id, req.user.id);
}
@Post("comments/:comment_id/like")
@ApiOperation({ summary: "Likes a comment" })
@ApiBearerAuth("JWT")
likeComment(@Param("comment_id") comment_id: string, @Request() req) {
return this.commentsService.like(comment_id, req.user.id);
}
}

View file

@ -1,6 +1,13 @@
import { Injectable } from "@nestjs/common"; import { File } from "@nest-lab/fastify-multer";
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import { PrismaService } from "src/services/prisma/prisma.service"; import { PrismaService } from "src/services/prisma/prisma.service";
import { S3Service } from "src/services/s3/s3.service"; import { S3Service } from "src/services/s3/s3.service";
import { selectCommentsWithReplies } from "./schemas/prisma_queries.schema";
@Injectable() @Injectable()
export class CommentsService { export class CommentsService {
@ -8,4 +15,171 @@ export class CommentsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly s3: S3Service, private readonly s3: S3Service,
) {} ) {}
async create(
kweek_id: string,
content: string,
user_id: string,
files: Array<File>,
) {
if (content.length > 300) {
throw new BadRequestException(
"Content too big. Must have 300 characters or lower",
);
}
// Verifies if the kweek_id is a kweek or a comment
const parentComment = await this.prisma.comments.findUnique({
where: { id: kweek_id },
});
let kweek = null;
if (parentComment === null) {
kweek = await this.prisma.kweek.findFirst({
where: { id: kweek_id },
});
if (kweek === null) {
throw new NotFoundException("Kweek/Comment not found");
}
}
const { id } = await this.prisma.comments.create({
data: {
content,
userId: user_id,
kweekId: kweek ? kweek.id : null,
parentId: parentComment ? parentComment.id : null,
},
select: {
id: true,
},
});
let attachments = [];
if (files && files.length > 0) {
attachments = await this.s3.multiImageUpload(id, files);
}
await this.prisma.comments.update({
where: {
id,
},
data: {
attachments,
},
});
return await this.prisma.comments.findFirst({ where: { id } });
}
async info(comment_id: string) {
const comment = await this.prisma.comments.findUnique({
where: { id: comment_id },
select: {
...selectCommentsWithReplies,
},
});
if (comment === null) {
throw new NotFoundException("Comment not found");
}
return comment;
}
async update(comment_id: string, user_id: string, content: string) {
let new_content = content;
const comment = await this.prisma.comments.findFirst({
where: { id: comment_id },
});
if (comment === null) {
throw new NotFoundException("Comment not found");
}
if (comment.userId !== user_id) {
throw new UnauthorizedException("Forbidden");
}
if (comment.content === content.trim()) {
new_content = comment.content;
}
return await this.prisma.comments.update({
where: {
id: comment_id,
},
data: {
content: new_content,
},
select: {
...selectCommentsWithReplies,
},
});
}
async delete(comment_id: string, user_id: string) {
const comment = await this.prisma.comments.findFirst({
where: { id: comment_id },
});
if (comment === null) {
throw new NotFoundException("Comment not found");
}
if (comment.userId !== user_id) {
throw new UnauthorizedException("Forbidden");
}
await this.s3.deleteFiles(comment.attachments);
await this.prisma.comments.delete({
where: {
id: comment_id,
},
});
return {};
}
async like(comment_id: string, user_id: string) {
const comment = await this.prisma.comments.findFirst({
where: {
id: comment_id,
},
});
if (comment === null) {
throw new NotFoundException("Comment not found");
}
const is_comment_already_liked = await this.prisma.commentLike.findFirst({
where: {
commentId: comment.id,
userId: user_id,
},
});
if (is_comment_already_liked !== null) {
await this.prisma.commentLike.deleteMany({
where: {
commentId: comment.id,
userId: user_id,
},
});
return {};
}
return await this.prisma.commentLike.create({
data: {
commentId: comment.id,
userId: user_id,
},
});
}
} }

View file

@ -0,0 +1,10 @@
import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const UpdateCommentSchema = z
.object({
content: z.string({ required_error: "Content is required" }).max(300),
})
.required();
export class UpdateCommentDTO extends createZodDto(UpdateCommentSchema) {}

View file

@ -3,7 +3,7 @@ import { z } from "nestjs-zod/z";
export const UpdateKweekSchema = z export const UpdateKweekSchema = z
.object({ .object({
id: z.string().toLowerCase().describe("New username - optional"), post_id: z.string({ required_error: "Post ID is required" }),
content: z.string({ required_error: "Content is required" }).max(300), content: z.string({ required_error: "Content is required" }).max(300),
}) })
.required(); .required();

View file

@ -1 +0,0 @@
export class Kweek {}

View file

@ -12,15 +12,18 @@ import {
UseInterceptors, UseInterceptors,
} from "@nestjs/common"; } from "@nestjs/common";
import { import {
ApiBadRequestResponse,
ApiBearerAuth, ApiBearerAuth,
ApiBody,
ApiConsumes, ApiConsumes,
ApiOperation, ApiOperation,
ApiTags, ApiTags,
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { ApiCreateKweek } from "src/decorators/create-kweek.decorator";
import { Public } from "src/decorators/public.decorator"; import { Public } from "src/decorators/public.decorator";
import { MultiFileValidation } from "src/validators/multi-file.validator"; import { MultiFileValidation } from "src/validators/multi_file.validator";
import { UpdateKweekDTO } from "./dto/kweeks/update_kweek.dto";
import { KweeksService } from "./kweeks.service"; import { KweeksService } from "./kweeks.service";
import { AttachmentsSchema } from "./schemas/attachments.schema";
@ApiTags("Kweeks") @ApiTags("Kweeks")
@Controller("kweeks") @Controller("kweeks")
@ -29,10 +32,11 @@ export class KweeksController {
@Post() @Post()
@ApiConsumes("multipart/form-data") @ApiConsumes("multipart/form-data")
@ApiCreateKweek("attachments") @ApiBody(AttachmentsSchema)
@UseInterceptors(FilesInterceptor("attachments", 4)) @UseInterceptors(FilesInterceptor("attachments", 4))
@ApiOperation({ summary: "Creates a kweek" }) @ApiOperation({ summary: "Creates a kweek" })
@ApiBearerAuth("JWT") @ApiBearerAuth("JWT")
@ApiBadRequestResponse({ description: "Content too long" })
create( create(
@UploadedFiles(new MultiFileValidation()) attachments: Array<File>, @UploadedFiles(new MultiFileValidation()) attachments: Array<File>,
@Body() body, @Body() body,
@ -51,44 +55,21 @@ export class KweeksController {
@Patch() @Patch()
@ApiOperation({ summary: "Updates a kweek content" }) @ApiOperation({ summary: "Updates a kweek content" })
@ApiBearerAuth("JWT") @ApiBearerAuth("JWT")
update(@Body() body: { id: string; content: string }) { update(@Body() body: UpdateKweekDTO, @Request() req) {
return this.kweeksService.update(body.id, body.content); return this.kweeksService.update(req.user.id, body.post_id, body.content);
} }
@Delete(":id") @Delete(":id")
@ApiOperation({ summary: "Deletes a kweek" }) @ApiOperation({ summary: "Deletes a kweek" })
@ApiBearerAuth("JWT") @ApiBearerAuth("JWT")
remove(@Param("id") id: string) { remove(@Param("id") id: string, @Request() req) {
return this.kweeksService.remove(id); return this.kweeksService.remove(req.user.id, id);
} }
@Post(":id/like") @Post(":id/like")
@ApiOperation({ summary: "Likes a kweek" }) @ApiOperation({ summary: "Likes a kweek" })
@ApiBearerAuth("JWT") @ApiBearerAuth("JWT")
likeKweek() {} likeKweek(@Param("id") kweek_id: string, @Request() req) {
return this.kweeksService.like(req.user.id, kweek_id);
@Public() }
@Get(":id/comments")
@ApiOperation({ summary: "Retrieves comments of a kweek" })
comments() {}
@Public()
@Get(":id/comments/:comment_id")
@ApiOperation({ summary: "Retrieves information about a comment" })
comment() {}
@Patch(":id/comments/:comment_id")
@ApiOperation({ summary: "Updates a comment content" })
@ApiBearerAuth("JWT")
updateComment() {}
@Delete(":id/comments/:comment_id")
@ApiOperation({ summary: "Deletes a comment" })
@ApiBearerAuth("JWT")
removeComment() {}
@Post(":id/comments/:comment_id/like")
@ApiOperation({ summary: "Likes a comment" })
@ApiBearerAuth("JWT")
likeComment() {}
} }

View file

@ -1,13 +1,14 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PrismaModule } from "src/services/prisma/prisma.module"; import { PrismaModule } from "src/services/prisma/prisma.module";
import { S3Service } from "src/services/s3/s3.service"; import { S3Service } from "src/services/s3/s3.service";
import { CommentsController } from "./comments.controller";
import { CommentsService } from "./comments.service"; import { CommentsService } from "./comments.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, CommentsController],
providers: [KweeksService, S3Service, CommentsService], providers: [KweeksService, S3Service, CommentsService],
}) })
export class KweeksModule {} export class KweeksModule {}

View file

@ -3,9 +3,11 @@ import {
BadRequestException, BadRequestException,
Injectable, Injectable,
NotFoundException, NotFoundException,
UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { PrismaService } from "src/services/prisma/prisma.service"; import { PrismaService } from "src/services/prisma/prisma.service";
import { S3Service } from "src/services/s3/s3.service"; import { S3Service } from "src/services/s3/s3.service";
import { selectComments, selectUser } from "./schemas/prisma_queries.schema";
@Injectable() @Injectable()
export class KweeksService { export class KweeksService {
@ -30,7 +32,11 @@ export class KweeksService {
}, },
}); });
const attachments = await this.s3.multiImageUploadToMinio(id, files); let attachments = [];
if (files && files.length > 0) {
attachments = await this.s3.multiImageUpload(id, files);
}
await this.prisma.kweek.update({ await this.prisma.kweek.update({
where: { where: {
@ -49,21 +55,13 @@ export class KweeksService {
where: { where: {
id, id,
}, },
select: { include: {
id: true, author: selectUser,
content: true, _count: {
attachments: true, select: { comments: true, likes: true },
createdAt: true,
updatedAt: true,
author: {
select: {
displayName: true,
username: true,
profileImage: true,
},
}, },
likes: true, likes: true,
comments: true, comments: selectComments,
}, },
}); });
@ -74,11 +72,102 @@ export class KweeksService {
return post; return post;
} }
update(id: string, content: string) { async update(user_id: string, post_id: string, content: string) {
return `This action updates a #${id} kweek`; let new_content = content;
const post = await this.prisma.kweek.findFirst({ where: { id: post_id } });
if (post === null) {
throw new NotFoundException("Post not found");
}
if (post.authorId !== user_id) {
throw new UnauthorizedException("Forbidden");
}
if (post.content === content.trim()) {
new_content = post.content;
}
return await this.prisma.kweek.update({
where: {
id: post_id,
},
data: {
content: new_content,
},
select: {
id: true,
content: true,
attachments: true,
createdAt: true,
updatedAt: true,
author: {
select: {
displayName: true,
username: true,
},
},
},
});
} }
remove(id: string) { async remove(user_id: string, id: string) {
return `This action removes a #${id} kweek`; const post = await this.prisma.kweek.findFirst({ where: { id } });
if (post === null) {
throw new NotFoundException("Post not found");
}
if (post.authorId !== user_id) {
throw new UnauthorizedException("Forbidden");
}
await this.s3.deleteFiles(post.attachments);
await this.prisma.kweek.delete({
where: {
id,
},
});
return {};
}
async like(user_id: string, kweek_id: string) {
const kweek = await this.prisma.kweek.findFirst({
where: {
id: kweek_id,
},
});
if (kweek === null) {
throw new NotFoundException("Post not found");
}
const is_kweek_already_liked = await this.prisma.kweekLike.findFirst({
where: {
kweekId: kweek.id,
userId: user_id,
},
});
if (is_kweek_already_liked !== null) {
await this.prisma.kweekLike.deleteMany({
where: {
kweekId: kweek.id,
userId: user_id,
},
});
return {};
}
return await this.prisma.kweekLike.create({
data: {
kweekId: kweek.id,
userId: user_id,
},
});
} }
} }

View file

@ -0,0 +1,18 @@
export const AttachmentsSchema = {
required: true,
schema: {
type: "object",
properties: {
content: {
type: "string",
},
attachments: {
type: "array",
items: {
type: "string",
format: "binary",
},
},
},
},
};

View file

@ -0,0 +1,50 @@
export const selectUser = {
select: {
displayName: true,
username: true,
profileImage: true,
},
};
const selectReplies = {
select: {
id: true,
content: true,
parentId: true,
attachments: true,
createdAt: true,
updatedAt: true,
user: selectUser,
},
};
export const selectCommentsWithReplies = {
id: true,
content: true,
kweekId: true,
attachments: true,
createdAt: true,
updatedAt: true,
user: selectUser,
_count: {
select: { replies: true, likes: true },
},
replies: {
...selectReplies,
},
};
export const selectComments = {
select: {
id: true,
content: true,
kweekId: true,
attachments: true,
createdAt: true,
updatedAt: true,
user: selectUser,
_count: {
select: { replies: true, likes: true },
},
},
};

View file

@ -12,35 +12,14 @@ import { Configuration } from "./configuration";
/* /*
--- Present --- --- Present ---
TODO: Remove the eval on buffer type validator. <--- TOP PRIORITY. THIS IS DANGEROUS. <-- Downgrade to 16.5.4 is the solution.
TODO: Find a way to get the static url of the uploaded file.
TODO: Remove single file upload function.
TODO: Remove `create-kweek` decorator.
TODO: Finish some routes. TODO: Finish some routes.
-> Kweek/Comments routes needs to be finished.
-> Delete User service needs more protection. -> Delete User service needs more protection.
TODO: Create tests. <- Check NestJS documentation for that.
TODO: Add `user` type to @nestjs/common ---> Request. TODO: Add `user` type to @nestjs/common ---> Request.
TODO: Remove some useless information on `README`.
TODO: Check if the Dockerfile is still working.
TODO: Replace Prisma to Kysely or something more low-level.
TODO: Replace Zod to Typebox.
--- Future ---
TODO: Kubernetes.
TODO: Send e-mails to the user when something happens to his account.
TODO: Add a authorization system. TODO: Add a authorization system.
TODO: Create a administrator dashboard showing statistics of the platform. <- Needs front-end first I guess... TODO: Send e-mails to the user when something happens to his account.
-> Only users with moderation/administration permission will be able to access it.
-> Users with moderation role can't access the statistics.
-> They will need the permission of an administrator to delete kweeks and users.
-> These users will be able to delete kweeks and terminate accounts with a obligatory reason.
-> This reason will be send to the person by e-mail and he can contest this decision.
TODO: Create a TOS.
TODO: Create the chat system. TODO: Create the chat system.
-> Initialize the websocket system first. -> Initialize the websocket system first.
TODO: Check compatibility with Bun. TODO: Create a TOS.
*/ */
async function bootstrap() { async function bootstrap() {

View file

@ -1,4 +1,8 @@
import { PutObjectCommand, PutObjectCommandInput } from "@aws-sdk/client-s3"; import {
DeleteObjectsCommand,
PutObjectCommand,
PutObjectCommandInput,
} from "@aws-sdk/client-s3";
import { File } from "@nest-lab/fastify-multer"; import { File } from "@nest-lab/fastify-multer";
import { Injectable, InternalServerErrorException } from "@nestjs/common"; import { Injectable, InternalServerErrorException } from "@nestjs/common";
import { InjectS3, S3 } from "nestjs-s3"; import { InjectS3, S3 } from "nestjs-s3";
@ -7,6 +11,9 @@ import { Configuration } from "src/configuration";
@Injectable() @Injectable()
export class S3Service { export class S3Service {
private bucket: string = Configuration.MINIO_DEFAULT_BUCKETS();
private endpoint: string = Configuration.MINIO_ENDPOINT();
constructor(@InjectS3() private readonly s3: S3) {} constructor(@InjectS3() private readonly s3: S3) {}
/* /*
@ -16,16 +23,16 @@ export class S3Service {
*/ */
/** /**
* Returns the image url if the upload to minio was successful. * Returns the image url if the upload was successful.
*/ */
async uploadImageToMinio(userID: string, buffer: Buffer): Promise<string> { async uploadImage(userID: string, buffer: Buffer): Promise<string> {
const compressedBuffer = await sharp(buffer) const compressedBuffer = await sharp(buffer)
.resize(200, 200) .resize(200, 200)
.webp({ quality: 70 }) .webp({ quality: 70 })
.toBuffer(); .toBuffer();
const params: PutObjectCommandInput = { const params: PutObjectCommandInput = {
Bucket: Configuration.MINIO_DEFAULT_BUCKETS(), Bucket: this.bucket,
Key: `profile_images/${userID}.webp`, Key: `profile_images/${userID}.webp`,
Body: compressedBuffer, Body: compressedBuffer,
ContentType: "image/webp", ContentType: "image/webp",
@ -36,7 +43,7 @@ export class S3Service {
const { ETag } = await this.s3.send(new PutObjectCommand(params)); const { ETag } = await this.s3.send(new PutObjectCommand(params));
if (ETag !== null) { if (ETag !== null) {
return `${Configuration.MINIO_ENDPOINT}/${Configuration.MINIO_DEFAULT_BUCKETS}/profile_images/${userID}.webp`; return `${this.endpoint}/${this.bucket}/profile_images/${userID}.webp`;
} }
throw new InternalServerErrorException( throw new InternalServerErrorException(
@ -44,7 +51,7 @@ export class S3Service {
); );
} }
async multiImageUploadToMinio(id: string, files: Array<File>) { async multiImageUpload(id: string, files: Array<File>) {
const buffers: Buffer[] = []; const buffers: Buffer[] = [];
if (files.length === 0) { if (files.length === 0) {
@ -61,23 +68,60 @@ export class S3Service {
} }
const uploadPromises = buffers.map(async (buffer, index) => { const uploadPromises = buffers.map(async (buffer, index) => {
return await this.multiUploadToMinio(buffer, id, index + 1); return await this.multiUpload(buffer, id, index + 1);
}); });
return Promise.all(uploadPromises); return Promise.all(uploadPromises);
} }
private async multiUploadToMinio(buffer: Buffer, id: string, index: number) { private async multiUpload(buffer: Buffer, id: string, index: number) {
const Key = `posts/${id}/${index}.webp`; const Key = `posts/${id}/${index}.webp`;
const params: PutObjectCommandInput = { const params: PutObjectCommandInput = {
Bucket: Configuration.MINIO_DEFAULT_BUCKETS(), Bucket: this.bucket,
Key, Key,
Body: buffer, Body: buffer,
ContentType: "image/webp", ContentType: "image/webp",
}; };
await this.s3.send(new PutObjectCommand(params)); await this.s3.send(new PutObjectCommand(params));
return `${Configuration.MINIO_ENDPOINT}/${Configuration.MINIO_DEFAULT_BUCKETS}/${Key}`; return `${this.endpoint}/${this.bucket}/${Key}`;
}
async deleteFiles(attachments: string[]) {
if (attachments.length === 0) {
return;
}
const keys = attachments.map((url) => {
try {
const parsed_url = new URL(url);
let key = parsed_url.pathname.substring(1);
if (key.startsWith(`${this.bucket}/`)) {
key = key.replace(`${this.bucket}`, "");
}
return key;
} catch (e) {
throw new InternalServerErrorException("Failed to parse URL");
}
});
const command = new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: keys.map((key) => ({ Key: key })),
},
});
try {
await this.s3.send(command);
} catch (error) {
throw new InternalServerErrorException(
"Failed to delete files from S3:",
error,
);
}
} }
} }

View file

@ -0,0 +1,16 @@
import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const FollowUserSchema = 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(),
})
.required();
export class FollowUserDTO extends createZodDto(FollowUserSchema) {}

View file

@ -25,12 +25,13 @@ import {
} from "@nestjs/swagger"; } from "@nestjs/swagger";
import { Public } from "src/decorators/public.decorator"; import { Public } from "src/decorators/public.decorator";
import { BufferValidator } from "src/validators/buffer.validator"; 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 { FollowUserDTO } from "./dto/follow_user.dto";
import { UpdateNameDTO } from "./dto/update-name.dto"; import { UpdateEmailDTO } from "./dto/update_email.dto";
import { UpdatePasswordDTO } from "./dto/update-password.dto"; import { UpdateNameDTO } from "./dto/update_name.dto";
import UploadImageSchema from "./schemas/upload-image.schema"; import { UpdatePasswordDTO } from "./dto/update_password.dto";
import UploadImageSchema from "./schemas/upload_image.schema";
import { UserService } from "./users.service"; import { UserService } from "./users.service";
@ApiTags("Users") @ApiTags("Users")
@ -50,6 +51,15 @@ export class UserController {
return this.userService.create(createUserDTO); return this.userService.create(createUserDTO);
} }
@Post("/follow")
@ApiOperation({ summary: "Follow/unfollow a user" })
@ApiCreatedResponse({ description: "Followed/unfollowed successfully" })
@ApiNotFoundResponse({ description: "User to follow not found" })
@ApiBearerAuth("JWT")
follow(@Body() { username }: FollowUserDTO, @Request() req) {
return this.userService.follow(req.user.id, username);
}
// GET // GET
@Get("/profile") @Get("/profile")
@ApiOperation({ summary: "Returns information about the logged user" }) @ApiOperation({ summary: "Returns information about the logged user" })

View file

@ -4,10 +4,10 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from "@nestjs/common"; } from "@nestjs/common";
import * as bcrypt from "bcrypt"; import * as argon2 from "argon2";
import { PrismaService } from "src/services/prisma/prisma.service"; import { PrismaService } from "src/services/prisma/prisma.service";
import { S3Service } from "src/services/s3/s3.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 { User } from "./types/user.type"; import { User } from "./types/user.type";
@ -88,8 +88,7 @@ export class UserService {
} }
// Password encryption // Password encryption
const salt = await bcrypt.genSalt(15); const hash = await argon2.hash(password);
const hash = await bcrypt.hash(password, salt);
const user = await this.prisma.user.create({ const user = await this.prisma.user.create({
data: { data: {
@ -107,6 +106,40 @@ export class UserService {
return user; return user;
} }
async follow(authenticated_id: string, username: string) {
const user_to_follow = await this.prisma.user.findFirst({
where: { username },
});
if (user_to_follow === null) {
throw new NotFoundException("User to follow not found");
}
const is_already_following = await this.prisma.follows.findFirst({
where: {
followerId: user_to_follow.id,
followingId: authenticated_id,
},
});
if (is_already_following !== null) {
await this.prisma.follows.deleteMany({
where: {
followerId: user_to_follow.id,
followingId: authenticated_id,
},
});
return {};
}
return await this.prisma.follows.create({
data: {
followerId: user_to_follow.id,
followingId: authenticated_id,
},
});
}
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({ const user = await this.prisma.user.findFirst({
where: { id }, where: { id },
@ -175,14 +208,13 @@ export class UserService {
where: { id }, where: { id },
}); });
const validatePassword = await bcrypt.compare(old_password, user.password); const validatePassword = await argon2.verify(user.password, old_password);
if (!validatePassword) { if (!validatePassword) {
throw new BadRequestException("Wrong password"); throw new BadRequestException("Wrong password");
} }
const salt = await bcrypt.genSalt(15); const hash = await argon2.hash(new_password);
const hash = await bcrypt.hash(new_password, salt);
await this.prisma.user.update({ await this.prisma.user.update({
where: { where: {
@ -197,7 +229,7 @@ export class UserService {
} }
async uploadImage(id: string, image: File) { async uploadImage(id: string, image: File) {
const url = await this.s3.uploadImageToMinio(id, image.buffer); const url = await this.s3.uploadImage(id, image.buffer);
return await this.prisma.user.update({ return await this.prisma.user.update({
where: { where: {

View file

@ -9,7 +9,7 @@ export class BufferValidator implements PipeTransform {
async transform(value: File) { async transform(value: File) {
const { fileTypeFromBuffer } = await (eval( const { fileTypeFromBuffer } = await (eval(
'import("file-type")', 'import("file-type")',
) as Promise<typeof import("file-type")>); // TODO: Find a way to remove this eval. This is very dangerous. TOP PRIORITY. <-- Downgrade to 16.5.4 should work. ) 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 buffer_type = await fileTypeFromBuffer(value.buffer); const buffer_type = await fileTypeFromBuffer(value.buffer);

View file

@ -1,24 +0,0 @@
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;
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
it("/ (GET)", () => {
return request(app.getHttpServer())
.get("/")
.expect(200)
.expect("Hello World!");
});
});

View file

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