feat: finished routes, updated packages, replacing npm to bun

Merge pull request #129 from hknsh/refactoring
This commit is contained in:
Cookie 2024-10-12 18:00:38 +01:00 committed by GitHub
commit bc40355fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 826 additions and 14112 deletions

View file

@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx --no -- commitlint --edit

View file

@ -1,4 +1 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged

View file

@ -6,16 +6,18 @@
</picture>
</p>
A simple RESTful API made with **NestJS** and **Fastify**.
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:
```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.
@ -25,24 +27,25 @@ 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:
```bash
$ npm run docker:db
bun docker:db
```
This will start the following services:
- **PostgreSQL**
- **Redis**
- **MinIO**
- **PostgreSQL**
- **Redis**
- **MinIO**
Apply the migrations to the database with the following command:
```bash
$ npm run migrate:dev
bun migrate:dev
```
And now, you can start the server with the command:
```bash
$ npm run dev:start
bun dev:start
```
You can check the documentation accessing the endpoint `/` in your browser
@ -50,7 +53,7 @@ You can check the documentation accessing the endpoint `/` in your browser
To run in production you can use the following command:
```bash
$ npm run docker
bun docker
```
This will start all the previous services and the back-end image.
@ -58,16 +61,15 @@ This will start all the previous services and the back-end image.
## 🗄️ Stack
This back-end uses the following stack:
- **Docker**
- **Fastify**
- **MinIO**
- **NestJS**
- **Passport**
- **PostgreSQL**
- **Prisma**
- **Redis**
- **Swagger**
- **Typescript**
- **NestJS**
- **Fastify**
- **Prisma**
- **MinIO**
- **PostgreSQL**
- **Redis**
- **Swagger**
- **Typescript**
## License

BIN
bun.lockb Executable file

Binary file not shown.

View file

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

View file

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

13863
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@
"docker": "docker compose --env-file docker.env up -d",
"docker:build": "docker build -t api . && docker compose up -d",
"docker:db": "docker compose -f docker-compose.db.yml up -d",
"lint": "npx @biomejs/biome check --apply .",
"lint": "npx @biomejs/biome check --write .",
"migrate:deploy": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev",
"migrate:dev:create": "prisma migrate dev --create-only",
@ -28,60 +28,62 @@
"test:watch": "jest --watch"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.502.0",
"@aws-sdk/client-s3": "^3.670.0",
"@fastify/helmet": "^11.1.1",
"@fastify/multipart": "^8.1.0",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^6.12.0",
"@nest-lab/fastify-multer": "^1.2.0",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
"@nest-lab/throttler-storage-redis": "^1.0.0",
"@nestjs/common": "^10.4.4",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.1",
"@nestjs/swagger": "^7.2.0",
"@nestjs/throttler": "^5.1.1",
"@prisma/client": "^5.9.1",
"bcrypt": "^5.1.1",
"file-type": "^19.0.0",
"ioredis": "^5.3.2",
"@nestjs/platform-fastify": "^10.4.4",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "6.2.1",
"@prisma/client": "^5.20.0",
"argon2": "^0.41.1",
"dotenv": "^16.4.5",
"dotenv-expand": "^11.0.6",
"file-type": "16.5.4",
"ioredis": "^5.4.1",
"nestjs-s3": "^2.0.1",
"nestjs-throttler-storage-redis": "^0.4.1",
"nestjs-zod": "^3.0.0",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.33.2"
"sharp": "^0.33.5",
"tstl": "^3.0.0"
},
"devDependencies": {
"@biomejs/biome": "1.5.3",
"@commitlint/cli": "^18.6.0",
"@commitlint/config-conventional": "^18.6.0",
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.3.3",
"@commitlint/cli": "^18.6.1",
"@commitlint/config-conventional": "^18.6.3",
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.4.4",
"@swc/cli": "^0.1.65",
"@swc/core": "^1.3.107",
"@swc/core": "1.7.25",
"@swc/jest": "^0.2.36",
"@types/bcrypt": "^5.0.2",
"@types/jest": "^29.5.2",
"@types/node": "^20.12.7",
"@types/passport-jwt": "^4.0.0",
"@types/jest": "^29.5.13",
"@types/node": "^20.16.11",
"@types/passport-jwt": "^4.0.1",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.0",
"husky": "^9.0.7",
"jest": "^29.5.0",
"lint-staged": "^15.2.1",
"prisma": "^5.12.1",
"@types/supertest": "^6.0.2",
"husky": "^9.1.6",
"jest": "^29.7.0",
"lint-staged": "^15.2.10",
"prisma": "^5.20.0",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
"ts-jest": "^29.1.0",
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"supertest": "^6.3.4",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
"typescript": "^5.6.3"
},
"jest": {
"moduleFileExtensions": [
@ -104,5 +106,9 @@
},
"lint-staged": {
"**/*.@(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 {
id String @id @default(uuid())
content String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kweekId String
kweek Kweek @relation(fields: [kweekId], references: [id], onDelete: Cascade)
likes CommentLike[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now())
id String @id @default(uuid())
content String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
kweekId String?
kweek Kweek? @relation(fields: [kweekId], references: [id], onDelete: Cascade)
likes CommentLike[]
attachments String[]
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 {

View file

@ -1,52 +1,52 @@
import { FastifyMulterModule } from "@nest-lab/fastify-multer";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
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 { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
import { ZodValidationPipe } from "nestjs-zod";
import { AuthModule } from "./auth/auth.module";
import { JwtAuthGuard } from "./auth/jwt-auth.guard";
import { Configuration } from "./configuration";
import { KweeksModule } from "./kweeks/kweeks.module";
import { UserModule } from "./users/users.module";
@Module({
imports: [
UserModule,
AuthModule,
ThrottlerModule.forRoot({
throttlers: [{ limit: 10, ttl: seconds(60) }],
storage: new ThrottlerStorageRedisService(Configuration.REDIS_URL()),
errorMessage: "Too many requests",
}),
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,
UserModule,
KweeksModule,
AuthModule,
S3Module.forRoot({
config: {
credentials: {
accessKeyId: process.env.MINIO_ROOT_USER, // CHANGE WHEN PRODUCTION TO S3
secretAccessKey: process.env.MINIO_ROOT_PASSWORD,
accessKeyId: Configuration.MINIO_ROOT_USER(),
secretAccessKey: Configuration.MINIO_ROOT_PASSWORD(),
},
region: "us-east-1",
endpoint: process.env.MINIO_ENDPOINT,
endpoint: Configuration.MINIO_ENDPOINT(),
forcePathStyle: true,
},
}),
],
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,

View file

@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { Configuration } from "src/configuration";
import { UserModule } from "src/users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
@ -13,7 +14,7 @@ import { LocalStrategy } from "./local.strategy";
UserModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET,
secret: Configuration.JWT_ACCESS_SECRET(),
signOptions: { expiresIn: "1d" }, // TODO: add refresh tokens
}),
],

View file

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

View file

@ -1,6 +1,7 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { Configuration } from "src/configuration";
type Payload = {
displayName: string;
@ -14,7 +15,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET,
secretOrKey: Configuration.JWT_ACCESS_SECRET(),
});
}

14
src/configuration.ts Normal file
View file

@ -0,0 +1,14 @@
import { Environment } from "./environment";
export namespace Configuration {
export const NODE_ENV = () => Environment.env.NODE_ENV;
export const SERVER_HOST = () => Environment.env.SERVER_HOST;
export const SERVER_PORT = () => Environment.env.SERVER_PORT;
export const JWT_ACCESS_SECRET = () => Environment.env.JWT_ACCESS_SECRET;
export const REDIS_URL = () => Environment.env.REDIS_URL;
export const MINIO_ROOT_USER = () => Environment.env.MINIO_ROOT_USER;
export const MINIO_ROOT_PASSWORD = () => Environment.env.MINIO_ROOT_PASSWORD;
export const MINIO_DEFAULT_BUCKETS = () =>
Environment.env.MINIO_DEFAULT_BUCKETS;
export const MINIO_ENDPOINT = () => Environment.env.MINIO_ENDPOINT;
}

View file

@ -1,27 +0,0 @@
// 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);
};

78
src/environment.ts Normal file
View file

@ -0,0 +1,78 @@
import dotenv from "dotenv";
import dotEnvExpand from "dotenv-expand";
import { Singleton } from "tstl";
import { z } from "nestjs-zod/z";
/**
* Global variables of the server.
*/
export class Environment {
public static get env(): IEnvironment {
return environments.get();
}
public static get node_env(): NodeEnv {
if (nodeEnvWrapper.value === undefined || nodeEnvWrapper.value === null) {
nodeEnvWrapper.value = environments.get().NODE_ENV;
}
return nodeEnvWrapper.value;
}
public setMode(mode: NodeEnv): void {
if (!["dev", "prod"].includes(mode)) {
throw new Error("Invalid NODE_ENV value, expected 'dev' or 'prod'");
}
nodeEnvWrapper.value = mode;
}
}
const EnvironmentSchema = z.object({
NODE_ENV: z.enum(["dev", "prod"]),
POSTGRES_HOST: z.string(),
POSTGRES_DB: z.string(),
POSTGRES_USER: z.string(),
POSTGRES_PASSWORD: z.string(),
POSTGRES_PORT: z.string().regex(/^[0-9]+$/),
DATABASE_URL: z.string(),
REDIS_HOST: z.string(),
REDIS_PORT: z.string().regex(/^[0-9]+$/),
REDIS_PASSWORD: z.string(),
REDIS_URL: z.string(),
SERVER_PORT: z.string().regex(/^[0-9]+$/),
SERVER_HOST: z.string(),
JWT_ACCESS_SECRET: z.string(),
MINIO_ROOT_USER: z.string(),
MINIO_ROOT_PASSWORD: z.string(),
MINIO_DEFAULT_BUCKETS: z.string(),
MINIO_ENDPOINT: z.string(),
});
type IEnvironment = z.infer<typeof EnvironmentSchema>;
type NodeEnv = "dev" | "prod";
interface INodeEnv {
value?: NodeEnv;
}
const nodeEnvWrapper: INodeEnv = {};
const environments = new Singleton(() => {
const env = dotenv.config();
dotEnvExpand.expand(env);
const parsedEnv = EnvironmentSchema.safeParse(process.env);
if (!parsedEnv.success) {
const errors = parsedEnv.error.format();
throw new Error(`Environment validation failed: ${JSON.stringify(errors)}`);
}
return parsedEnv.data;
});

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 { S3Service } from "src/services/s3/s3.service";
import { selectCommentsWithReplies } from "./schemas/prisma_queries.schema";
@Injectable()
export class CommentsService {
@ -8,4 +15,171 @@ export class CommentsService {
private readonly prisma: PrismaService,
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
.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),
})
.required();

View file

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

View file

@ -12,15 +12,18 @@ import {
UseInterceptors,
} from "@nestjs/common";
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
import { ApiCreateKweek } from "src/decorators/create-kweek.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 { AttachmentsSchema } from "./schemas/attachments.schema";
@ApiTags("Kweeks")
@Controller("kweeks")
@ -29,10 +32,11 @@ export class KweeksController {
@Post()
@ApiConsumes("multipart/form-data")
@ApiCreateKweek("attachments")
@ApiBody(AttachmentsSchema)
@UseInterceptors(FilesInterceptor("attachments", 4))
@ApiOperation({ summary: "Creates a kweek" })
@ApiBearerAuth("JWT")
@ApiBadRequestResponse({ description: "Content too long" })
create(
@UploadedFiles(new MultiFileValidation()) attachments: Array<File>,
@Body() body,
@ -51,44 +55,21 @@ export class KweeksController {
@Patch()
@ApiOperation({ summary: "Updates a kweek content" })
@ApiBearerAuth("JWT")
update(@Body() body: { id: string; content: string }) {
return this.kweeksService.update(body.id, body.content);
update(@Body() body: UpdateKweekDTO, @Request() req) {
return this.kweeksService.update(req.user.id, body.post_id, body.content);
}
@Delete(":id")
@ApiOperation({ summary: "Deletes a kweek" })
@ApiBearerAuth("JWT")
remove(@Param("id") id: string) {
return this.kweeksService.remove(id);
remove(@Param("id") id: string, @Request() req) {
return this.kweeksService.remove(req.user.id, id);
}
@Post(":id/like")
@ApiOperation({ summary: "Likes a kweek" })
@ApiBearerAuth("JWT")
likeKweek() {}
@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() {}
likeKweek(@Param("id") kweek_id: string, @Request() req) {
return this.kweeksService.like(req.user.id, kweek_id);
}
}

View file

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

View file

@ -3,9 +3,11 @@ import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import { PrismaService } from "src/services/prisma/prisma.service";
import { S3Service } from "src/services/s3/s3.service";
import { selectComments, selectUser } from "./schemas/prisma_queries.schema";
@Injectable()
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({
where: {
@ -49,21 +55,13 @@ export class KweeksService {
where: {
id,
},
select: {
id: true,
content: true,
attachments: true,
createdAt: true,
updatedAt: true,
author: {
select: {
displayName: true,
username: true,
profileImage: true,
},
include: {
author: selectUser,
_count: {
select: { comments: true, likes: true },
},
likes: true,
comments: true,
comments: selectComments,
},
});
@ -74,11 +72,102 @@ export class KweeksService {
return post;
}
update(id: string, content: string) {
return `This action updates a #${id} kweek`;
async update(user_id: string, post_id: string, content: string) {
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) {
return `This action removes a #${id} kweek`;
async remove(user_id: string, id: string) {
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

@ -1,4 +1,4 @@
import * as helmet from "@fastify/helmet";
import helmet from "@fastify/helmet";
import { NestFactory } from "@nestjs/core";
import {
FastifyAdapter,
@ -7,6 +7,20 @@ import {
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { patchNestJsSwagger } from "nestjs-zod";
import { AppModule } from "./app.module";
import { Configuration } from "./configuration";
/*
--- Present ---
TODO: Finish some routes.
-> Delete User service needs more protection.
TODO: Add `user` type to @nestjs/common ---> Request.
TODO: Add a authorization system.
TODO: Send e-mails to the user when something happens to his account.
TODO: Create the chat system.
-> Initialize the websocket system first.
TODO: Create a TOS.
*/
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
@ -44,6 +58,6 @@ async function bootstrap() {
await app.register(helmet);
await app.listen(process.env.SERVER_PORT, process.env.SERVER_HOST);
await app.listen(Configuration.SERVER_PORT(), Configuration.SERVER_HOST);
}
bootstrap();

View file

@ -1,24 +1,38 @@
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 { Injectable, InternalServerErrorException } from "@nestjs/common";
import { InjectS3, S3 } from "nestjs-s3";
import sharp from "sharp";
import { Configuration } from "src/configuration";
@Injectable()
export class S3Service {
private bucket: string = Configuration.MINIO_DEFAULT_BUCKETS();
private endpoint: string = Configuration.MINIO_ENDPOINT();
constructor(@InjectS3() private readonly s3: S3) {}
/*
TODO: Remove single image upload since we can use the multiple one.
TODO: Find a way to automatically get the image complete URL.
-> iirc, S3 api has a function for that.
*/
/**
* 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)
.resize(200, 200)
.webp({ quality: 70 })
.toBuffer();
const params: PutObjectCommandInput = {
Bucket: process.env.MINIO_DEFAULT_BUCKETS,
Bucket: this.bucket,
Key: `profile_images/${userID}.webp`,
Body: compressedBuffer,
ContentType: "image/webp",
@ -29,7 +43,7 @@ export class S3Service {
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`;
return `${this.endpoint}/${this.bucket}/profile_images/${userID}.webp`;
}
throw new InternalServerErrorException(
@ -37,7 +51,7 @@ export class S3Service {
);
}
async multiImageUploadToMinio(id: string, files: Array<File>) {
async multiImageUpload(id: string, files: Array<File>) {
const buffers: Buffer[] = [];
if (files.length === 0) {
@ -54,23 +68,60 @@ export class S3Service {
}
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);
}
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 params: PutObjectCommandInput = {
Bucket: process.env.MINIO_DEFAULT_BUCKETS,
Bucket: this.bucket,
Key,
Body: buffer,
ContentType: "image/webp",
};
await this.s3.send(new PutObjectCommand(params));
return `${process.env.MINIO_ENDPOINT}/${process.env.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";
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";
import { UpdateNameDTO } from "./dto/update-name.dto";
import { UpdatePasswordDTO } from "./dto/update-password.dto";
import UploadImageSchema from "./schemas/upload-image.schema";
import UploadImageValidator from "src/validators/upload_image.validator";
import { CreateUserDTO } from "./dto/create_user.dto";
import { FollowUserDTO } from "./dto/follow_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 { UserService } from "./users.service";
@ApiTags("Users")
@ -50,6 +51,15 @@ export class UserController {
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("/profile")
@ApiOperation({ summary: "Returns information about the logged user" })

View file

@ -4,10 +4,10 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common";
import * as bcrypt from "bcrypt";
import * as argon2 from "argon2";
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 { User } from "./types/user.type";
@ -56,6 +56,12 @@ export class UserService {
attachments: true,
createdAt: true,
updatedAt: true,
_count: {
select: {
comments: true,
likes: true,
},
},
},
},
},
@ -88,8 +94,7 @@ export class UserService {
}
// Password encryption
const salt = await bcrypt.genSalt(15);
const hash = await bcrypt.hash(password, salt);
const hash = await argon2.hash(password);
const user = await this.prisma.user.create({
data: {
@ -107,6 +112,40 @@ export class UserService {
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 }> {
const user = await this.prisma.user.findFirst({
where: { id },
@ -175,14 +214,13 @@ export class UserService {
where: { id },
});
const validatePassword = await bcrypt.compare(old_password, user.password);
const validatePassword = await argon2.verify(user.password, old_password);
if (!validatePassword) {
throw new BadRequestException("Wrong password");
}
const salt = await bcrypt.genSalt(15);
const hash = await bcrypt.hash(new_password, salt);
const hash = await argon2.hash(new_password);
await this.prisma.user.update({
where: {
@ -197,7 +235,7 @@ export class UserService {
}
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({
where: {
@ -214,6 +252,7 @@ export class UserService {
async delete(id: string) {
// TODO: Add validation for safety (like e-mail confirmation or password)
// TODO: Delete the user's attachments when deleting, like Kweeks attachments and profile pictures.
try {
await this.prisma.user.deleteMany({ where: { id } });
return { message: "User deleted" };

View file

@ -1,5 +1,6 @@
import { File } from "@nest-lab/fastify-multer";
import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common";
import { fromBuffer } from "file-type";
/**
* Magic Number validation with `file-type` module.
@ -7,12 +8,8 @@ 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")>);
const ALLOWED_MIMES = ["image/jpeg", "image/png", "image/webp"];
const buffer_type = await fileTypeFromBuffer(value.buffer);
const buffer_type = await fromBuffer(value.buffer);
if (!buffer_type || !ALLOWED_MIMES.includes(buffer_type.mime)) {
throw new BadRequestException(

View file

@ -1,5 +1,6 @@
import { File } from "@nest-lab/fastify-multer";
import { BadRequestException, Injectable, PipeTransform } from "@nestjs/common";
import { fromBuffer } from "file-type";
@Injectable()
export class MultiFileValidation implements PipeTransform {
@ -8,16 +9,12 @@ export class MultiFileValidation implements PipeTransform {
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);
const buffer_type = await fromBuffer(buffer);
if (!buffer_type || !ALLOWED_MIMES.includes(buffer_type.mime)) {
errors.push({

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"
}
}

View file

@ -2,6 +2,7 @@
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"esModuleInterop": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,