mirror of
https://github.com/hknsh/project-knedita.git
synced 2024-11-28 09:31:16 +00:00
feat: finished routes, updated packages, replacing npm to bun
Merge pull request #129 from hknsh/refactoring
This commit is contained in:
commit
bc40355fa2
45 changed files with 826 additions and 14112 deletions
|
@ -1,4 +1 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit
|
||||
|
|
|
@ -1,4 +1 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
npx lint-staged
|
||||
|
|
44
README.md
44
README.md
|
@ -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
BIN
bun.lockb
Executable file
Binary file not shown.
|
@ -1,5 +1,3 @@
|
|||
version: '3.8'
|
||||
|
||||
networks:
|
||||
localstack-net:
|
||||
name: localstack-net
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
version: '3.8'
|
||||
|
||||
networks:
|
||||
localstack-net:
|
||||
name: localstack-net
|
||||
|
|
13863
package-lock.json
generated
13863
package-lock.json
generated
File diff suppressed because it is too large
Load diff
78
package.json
78
package.json
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Comments" ADD COLUMN "attachments" TEXT[];
|
|
@ -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;
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}),
|
||||
],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
14
src/configuration.ts
Normal 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;
|
||||
}
|
|
@ -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
78
src/environment.ts
Normal 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;
|
||||
});
|
85
src/kweeks/comments.controller.ts
Normal file
85
src/kweeks/comments.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
10
src/kweeks/dto/comments/update_comment.dto.ts
Normal file
10
src/kweeks/dto/comments/update_comment.dto.ts
Normal 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) {}
|
|
@ -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();
|
|
@ -1 +0,0 @@
|
|||
export class Kweek {}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
18
src/kweeks/schemas/attachments.schema.ts
Normal file
18
src/kweeks/schemas/attachments.schema.ts
Normal 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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
50
src/kweeks/schemas/prisma_queries.schema.ts
Normal file
50
src/kweeks/schemas/prisma_queries.schema.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
};
|
18
src/main.ts
18
src/main.ts
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
16
src/users/dto/follow_user.dto.ts
Normal file
16
src/users/dto/follow_user.dto.ts
Normal 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) {}
|
|
@ -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" })
|
||||
|
|
|
@ -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" };
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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({
|
|
@ -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!");
|
||||
});
|
||||
});
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
}
|
||||
}
|
|
@ -2,6 +2,7 @@
|
|||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
|
|
Loading…
Reference in a new issue