Merge pull request #60 from hknsh/new-fixes

feat: nestjs migration, new features
This commit is contained in:
Cookie 2024-02-16 12:41:16 +00:00 committed by GitHub
commit 74f411d7b5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
161 changed files with 10364 additions and 11758 deletions

3
.commitlintrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"extends": ["@commitlint/config-conventional"]
}

View file

@ -1,4 +1,11 @@
.env .env
node_modules/ node_modules/
.vscode/ .vscode/
client/ Dockerfile
.dockerignore
npm-debug.log
dist
.env.example
package-lock_copy.json
package_copy.json
docker.env.example

View file

@ -14,20 +14,19 @@ DB_HOST=localhost
DATABASE_URL=postgresql://<placeholder>:<placeholder>@${DB_HOST}:5432/${POSTGRES_DB}?schema=<placeholder> DATABASE_URL=postgresql://<placeholder>:<placeholder>@${DB_HOST}:5432/${POSTGRES_DB}?schema=<placeholder>
# Redis # Redis
REDIS_HOST=127.0.0.1 # localhost (for docker use redis) REDIS_HOST=127.0.0.1 # localhost (for docker use `redis`)
REDIS_PORT=6379 REDIS_PORT=6379
REDIS_PASSWORD=<placehoder> REDIS_PASSWORD=<same_as_defined_in_docker_compose_file>
# Express # Fastify
SERVER_PORT=<placeholder> SERVER_PORT=<placeholder>
CLIENT_URL=<placeholder> SERVER_HOST=<placeholder>
# Security # Security
JWT_ACCESS_SECRET=<placeholder> JWT_ACCESS_SECRET=<placeholder>
# Localstack - The data can be fake (Change this to real data on production) # Minio
SERVICES=s3 MINIO_ROOT_USER=<username>
AWS_ACCESS_KEY_ID=<placeholder> MINIO_ROOT_PASSWORD=<password_more_or_equal_to_8_characters>
AWS_SECRET_ACCESS_KEY=<placeholder> MINIO_DEFAULT_BUCKETS=<bucket_name>
AWS_DEFAULT_REGION=us-east-1 MINIO_ENDPOINT=<url>
AWS_DEFAULT_OUTPUT=json

View file

@ -1,2 +0,0 @@
node_modules
dist

View file

@ -1,17 +0,0 @@
{
"env": {
"es2021": true,
"node": true
},
"plugins": ["prettier"],
"extends": ["plugin:prettier/recommended"],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"project": ["./tsconfig.json"]
},
"rules": {
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/explicit-function-return-type": 1
}
}

18
.github/workflows/pull_request.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: Biome
on:
push:
pull_request:
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
version: latest
- name: Run Biome
run: biome check --apply .

45
.gitignore vendored
View file

@ -1,11 +1,44 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
old_port
.git .git
node_modules
.env .env
prisma/*.db prisma/*.db
.DS_Store
prisma/migrations/dev prisma/migrations/dev
dist
pnpm-lock.yaml
package_backup.json
logs/
docker.env docker.env
package_copy.json
package-lock_copy.json

View file

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

4
.husky/pre-commit Normal file
View file

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

View file

@ -1,8 +0,0 @@
{
"semi": false,
"singleQuote": true,
"arrowParens": "avoid",
"useTabs": false,
"endOfLine": "lf",
"tabWidth": 2
}

22
.swcrc
View file

@ -1,26 +1,14 @@
{ {
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": true,
"jsc": { "jsc": {
"target": "esnext",
"parser": { "parser": {
"syntax": "typescript", "syntax": "typescript",
"tsx": false,
"decorators": true, "decorators": true,
"dynamicImport": true "dynamicImport": true
}, },
"target": "es2020", "baseUrl": "./"
"baseUrl": "./src",
"paths": {
"clients/*": ["clients/*"],
"config/*": ["config/*"],
"controllers/*": ["controllers/*"],
"interfaces/*": ["interfaces/*"],
"helpers/*": ["helpers/*"],
"middlewares/*": ["middlewares/*"],
"services/*": ["services/*"]
}
}, },
"exclude": ["@types/", "interfaces/"], "minify": false
"module": {
"type": "commonjs"
},
"minify": true
} }

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["biomejs.biome"]
}

View file

@ -1,34 +1,50 @@
FROM node:18 as builder # LOCAL
FROM node:20-alpine AS dev
# Create app dir WORKDIR /usr/src/app
WORKDIR /app
COPY package*.json ./ COPY --chown=node:node package*.json ./
COPY prisma ./prisma/ COPY --chown=node:node prisma ./prisma/
RUN npm install -D @swc/cli @swc/core
RUN npm install
COPY . .
RUN npm run build
# Stage 2
FROM node:18
WORKDIR /app
RUN npm i pm2 -g
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/prisma ./prisma/
COPY --from=builder /app/dist ./dist/
COPY --from=builder /app/docker.env ./
RUN mv docker.env .env
RUN npm ci RUN npm ci
EXPOSE 8080 COPY --chown=node:node . .
CMD ["npm", "run", "prod:start"] USER node
# BUILD
FROM node:20-alpine AS build
WORKDIR /usr/src/app
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=dev /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=dev /usr/src/app/prisma ./prisma/
COPY --chown=node:node .husky ./.husky
COPY --chown=node:node tsconfig.json tsconfig.build.json ./
COPY --chown=node:node docker.env ./.env
COPY --chown=node:node . .
RUN npm install husky -g
RUN npm run prisma:generate
RUN npm run build
ENV NODE_ENV production
RUN npm ci --only=production && npm cache clean --force
USER node
# PROD
FROM node:20-alpine as production
COPY --chown=node:node --from=build /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=build /usr/src/app/dist ./dist
COPY --chown=node:node --from=build /usr/src/app/prisma ./prisma
COPY --chown=node:node --from=build /usr/src/app/.husky ./.husky
COPY --chown=node:node --from=build /usr/src/app/.env ./
COPY --chown=node:node --from=build /usr/src/app/package*.json ./
CMD ["npm", "run" , "prod"]

View file

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2023 CookieDasora Copyright (c) 2024 CookieDasora
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -1,30 +1,73 @@
<p align="center"> <p align="center">
<img src="./banner.svg" alt="LocalStack - A fully functional local cloud stack"> <picture>
<source media="(prefers-color-scheme: dark)" srcset="./resources/logo-light.svg">
<source media="(prefers-color-scheme: light)" srcset="./resources/logo-dark.svg">
<img alt="Project Knedita" src="./resources/logo-light.svg" width="700">
</picture>
</p> </p>
## Stack A simple RESTful API made with **NestJS** and **Fastify**.
**Client**: NextJS, TailwindCSS and Radix UI Icons. ### 🚀 Preparing the environment
You can find the front-end [here](https://github.com/CookieDasora/project-knedita-client)
**Server**: ExpressJS, Jest, Docker, Postgresql, Redis, Prisma, AWS, SWC and Typescript Make sure that you have Node, NPM, Docker and Docker Compose installed on your computer.
## To-do - Backend First, install the necessary packages with the following commands:
- Create/update/delete Posts ✅ ```bash
- Add post attachments $ npm i
- Create/update/delete Users ✅ ```
- Password recuperation
- Two step verification 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.
- Able to choose a profile picture✅
- Probably gonna use LocalStack to mock Amazon S3✅ You can find the templates for those files on `.env.example` and `docker.env.example`.
- Image compression ✅
- Following/unfollowing features ✅ To run the necessary services you can execute the following command:
- Like posts ✅
- Authentication ✅ ```bash
- Add more verification (like, if the password is too short) ✅ $ npm run docker:db
- Set display name ✅ ```
- Add rate limit ✅
This will start the following services:
- **PostgreSQL**
- **Redis**
- **MinIO**
Apply the migrations to the database with the following command:
```bash
$ npm run migrate:dev
```
And now, you can start the server with the command:
```bash
$ npm run dev:start
```
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
```
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**
## License ## License

20
biome.json Normal file
View file

@ -0,0 +1,20 @@
{
"$schema": "https://biomejs.dev/schemas/1.5.3/schema.json",
"organizeImports": {
"enabled": true
},
"files": {
"ignore": ["dist/**", "node_modules"]
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"parser": {
"unsafeParameterDecoratorsEnabled": true
}
}
}

View file

@ -1,3 +1,3 @@
module.exports = { module.exports = {
extends: ['@commitlint/config-conventional'], extends: ["@commitlint/config-conventional"],
} };

View file

@ -4,6 +4,9 @@ networks:
localstack-net: localstack-net:
name: localstack-net name: localstack-net
driver: bridge driver: bridge
minio_network:
name: minio_network
driver: bridge
services: services:
postgres: postgres:
@ -27,24 +30,23 @@ services:
volumes: volumes:
- redis:/data - redis:/data
localstack: minio:
image: localstack/localstack image: bitnami/minio
container_name: localstack_main
restart: unless-stopped restart: unless-stopped
networks:
- localstack-net
ports: ports:
- 4566:4566 - '9000:9000'
- 4572:4572 - '9001:9001'
networks:
- minio_network
volumes:
- 'minio_data:/data'
env_file: env_file:
- docker.env - docker.env
volumes:
- localstack:/data
- '/var/run/docker.sock:/var/run/docker.sock'
volumes: volumes:
postgres: postgres:
name: backend-db name: backend-db
redis: redis:
driver: local driver: local
localstack: minio_data:
driver: local

View file

@ -4,23 +4,11 @@ networks:
localstack-net: localstack-net:
name: localstack-net name: localstack-net
driver: bridge driver: bridge
minio_network:
name: minio_network
driver: bridge
services: services:
api:
container_name: api
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile
ports:
- 8080:8080
depends_on:
- postgres
- redis
- localstack
env_file:
- docker.env
postgres: postgres:
image: postgres:alpine image: postgres:alpine
restart: unless-stopped restart: unless-stopped
@ -32,20 +20,18 @@ services:
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
localstack: minio:
image: localstack/localstack image: bitnami/minio
container_name: localstack_main
restart: unless-stopped restart: unless-stopped
networks:
- localstack-net
ports: ports:
- 4566:4566 - '9000:9000'
- 4572:4572 - '9001:9001'
networks:
- minio_network
volumes:
- 'minio_data:/data'
env_file: env_file:
- docker.env - docker.env
volumes:
- localstack:/data
- '/var/run/docker.sock:/var/run/docker.sock'
redis: redis:
image: redis:alpine image: redis:alpine
@ -57,9 +43,25 @@ services:
volumes: volumes:
- redis:/data - redis:/data
api:
container_name: api
restart: unless-stopped
build:
context: .
dockerfile: Dockerfile
ports:
- 3000:3000
depends_on:
- postgres
- redis
- minio
env_file:
- docker.env
volumes: volumes:
postgres: postgres:
name: backend-db name: backend-db
redis: redis:
driver: local driver: local
localstack: minio_data:
driver: local

View file

@ -5,26 +5,27 @@ NODE_ENV=production
POSTGRES_DB=<placeholder> POSTGRES_DB=<placeholder>
POSTGRES_USER=<placeholder> POSTGRES_USER=<placeholder>
POSTGRES_PASSWORD=<placeholder> POSTGRES_PASSWORD=<placeholder>
# Use this hostname on Docker
DB_HOST=postgres DB_HOST=postgres
DATABASE_URL=postgresql://<placeholder>:<placeholder>@${DB_HOST}:<placeholder>/${POSTGRES_DB}?schema=<placeholder> DATABASE_URL=postgresql://<USER>:<PASS>@${DB_HOST}:<PORT>/${POSTGRES_DB}?schema=<PROD-SCHEMA>
# Redis # Redis
# Use this hostname on Docker
REDIS_HOST=redis REDIS_HOST=redis
REDIS_PORT=<placeholder> REDIS_PORT=<placeholder>
REDIS_PASSWORD=<placeholder> REDIS_PASSWORD=<same_as_defined_in_docker_compose_file>
# Express # Fastify
SERVER_PORT=<placeholder> SERVER_PORT=<placeholder>
SERVER_HOST=<placeholder>
# Security # Security
JWT_ACCESS_SECRET=<placeholder> JWT_ACCESS_SECRET=<placeholder>
# Localstack - The data can be fake # Minio
SERVICES=s3 MINIO_ROOT_USER=<username>
AWS_ACCESS_KEY_ID=<placeholder> MINIO_ROOT_PASSWORD=<password_more_or_equal_to_8_characters>
AWS_SECRET_ACCESS_KEY=<placeholder> MINIO_DEFAULT_BUCKETS=<bucket_name>
AWS_DEFAULT_REGION=us-east-1 MINIO_ENDPOINT=<url>
AWS_DEFAULT_OUTPUT=json
AWS_BUCKET=<placeholder>

10
nest-cli.json Normal file
View file

@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "swc",
"typeCheck": true
}
}

14963
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,94 +1,116 @@
{ {
"name": "social-media-app", "name": "project-knedita",
"version": "0.0.1", "version": "0.1.0",
"description": "A social media", "description": "A open-source social media",
"author": "hknsh",
"license": "MIT",
"scripts": { "scripts": {
"build": "swc src -d dist", "build": "nest build",
"dev:start": "ts-node-dev -r tsconfig-paths/register --transpile-only --respawn src/server.ts", "dev:start": "nest start --watch",
"dev:debug": "nest start --debug --watch",
"docker": "docker compose --env-file docker.env up -d", "docker": "docker compose --env-file docker.env up -d",
"docker:build": "docker build -t api . && docker compose up -d", "docker:build": "docker build -t api . && docker compose up -d",
"docker:db": "docker compose -f docker-compose.db.yml up -d", "docker:db": "docker compose -f docker-compose.db.yml up -d",
"docker:seed": "docker exec -it api npm run prisma:seed", "lint": "npx @biomejs/biome check --apply .",
"lint": "eslint --ignore-path .eslintignore --ext .js,.ts .", "migrate:deploy": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev", "migrate:dev": "prisma migrate dev",
"migrate:dev:create": "prisma migrate dev --create-only", "migrate:dev:create": "prisma migrate dev --create-only",
"migrate:reset": "prisma migrate reset", "migrate:reset": "prisma migrate reset",
"prepare": "husky install",
"prisma:generate": "npx prisma generate", "prisma:generate": "npx prisma generate",
"prisma:seed": "prisma db seed",
"prisma:studio": "npx prisma studio", "prisma:studio": "npx prisma studio",
"prod:start": "npx prisma migrate deploy && pm2-runtime start dist/server.js", "prepare": "husky",
"test": "vitest run" "prod": "npm run migrate:deploy && node dist/main",
}, "start": "nest start",
"ts-standard": { "test": "jest",
"project": "tsconfig.json", "test:cov": "jest --coverage",
"ignore": [ "test:e2e": "jest --config ./test/jest-e2e.json",
"prisma/*", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"dist" "test:watch": "jest --watch"
]
},
"author": "Cookie",
"license": "MIT",
"devDependencies": {
"@commitlint/cli": "^17.7.2",
"@commitlint/config-conventional": "^17.7.0",
"@faker-js/faker": "^8.4.0",
"@swc/cli": "^0.1.62",
"@swc/core": "^1.3.107",
"@types/bcrypt": "^5.0.2",
"@types/compression": "^1.7.5",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.0",
"@types/express": "^4.17.19",
"@types/jsonwebtoken": "^9.0.2",
"@types/morgan": "^1.9.4",
"@types/multer-s3": "^3.0.0",
"@types/node": "^20.11.5",
"@types/supertest": "^2.0.12",
"@types/swagger-ui-express": "^4.1.4",
"@types/validator": "^13.7.17",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.7.5",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-standard-with-typescript": "^43.0.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-n": "^16.6.2",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-promise": "^6.1.1",
"husky": "^8.0.3",
"nodemon": "^3.0.1",
"pm2": "^5.3.1",
"prettier": "^3.2.4",
"prisma": "^5.7.0",
"supertest": "^6.3.3",
"ts-node-dev": "^2.0.0",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.3.3",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^0.34.6"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^5.4.1", "@aws-sdk/client-s3": "^3.502.0",
"aws-sdk": "^2.1545.0", "@fastify/helmet": "^11.1.1",
"bcrypt": "^5.1.0", "@fastify/multipart": "^8.1.0",
"compression": "^1.7.4", "@fastify/static": "^6.12.0",
"cors": "^2.8.5", "@nest-lab/fastify-multer": "^1.2.0",
"dotenv": "^16.3.2", "@nestjs/common": "^10.0.0",
"express": "^4.18.2", "@nestjs/config": "^3.1.1",
"express-rate-limit": "^7.1.1", "@nestjs/core": "^10.0.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@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", "ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.0", "nestjs-s3": "^2.0.1",
"morgan": "^1.10.0", "nestjs-throttler-storage-redis": "^0.4.1",
"multer": "^1.4.5-lts.1", "nestjs-zod": "^3.0.0",
"multer-s3": "^3.0.1", "passport": "^0.7.0",
"rate-limit-redis": "^4.0.0", "passport-jwt": "^4.0.1",
"redis": "^4.6.7", "passport-local": "^1.0.0",
"sharp": "^0.32.3", "reflect-metadata": "^0.1.13",
"socket.io": "^4.7.2", "rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.0", "sharp": "^0.33.2"
"validator": "^13.9.0", },
"winston": "^3.11.0", "devDependencies": {
"yaml": "^2.3.4" "@biomejs/biome": "1.5.3",
"@commitlint/cli": "^18.6.0",
"@commitlint/config-conventional": "^18.6.0",
"@nestjs/cli": "^10.0.0",
"@nestjs/schematics": "^10.0.0",
"@nestjs/testing": "^10.0.0",
"@swc/cli": "^0.1.65",
"@swc/core": "^1.3.107",
"@swc/jest": "^0.2.31",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.17",
"@types/jest": "^29.5.2",
"@types/node": "^20.3.1",
"@types/passport-jwt": "^4.0.0",
"@types/passport-local": "^1.0.38",
"@types/supertest": "^6.0.0",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"eslint": "^8.42.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"husky": "^9.0.7",
"jest": "^29.5.0",
"lint-staged": "^15.2.1",
"prettier": "^3.0.0",
"prisma": "^5.9.1",
"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",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.1.3"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": [
"@swc/jest"
]
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
},
"lint-staged": {
"**/*.@(js|ts|json)": "biome check --apply --no-errors-on-unmatched"
} }
} }

View file

@ -0,0 +1,63 @@
/*
Warnings:
- You are about to drop the column `postId` on the `Comments` table. All the data in the column will be lost.
- You are about to drop the `Post` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `PostLike` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `kweekId` to the `Comments` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "Comments" DROP CONSTRAINT "Comments_postId_fkey";
-- DropForeignKey
ALTER TABLE "Post" DROP CONSTRAINT "Post_authorId_fkey";
-- DropForeignKey
ALTER TABLE "PostLike" DROP CONSTRAINT "PostLike_postId_fkey";
-- DropForeignKey
ALTER TABLE "PostLike" DROP CONSTRAINT "PostLike_userId_fkey";
-- AlterTable
ALTER TABLE "Comments" DROP COLUMN "postId",
ADD COLUMN "kweekId" TEXT NOT NULL;
-- DropTable
DROP TABLE "Post";
-- DropTable
DROP TABLE "PostLike";
-- CreateTable
CREATE TABLE "Kweek" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Kweek_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "KweekLike" (
"id" TEXT NOT NULL,
"kweekId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "KweekLike_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Kweek" ADD CONSTRAINT "Kweek_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KweekLike" ADD CONSTRAINT "KweekLike_kweekId_fkey" FOREIGN KEY ("kweekId") REFERENCES "Kweek"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "KweekLike" ADD CONSTRAINT "KweekLike_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Comments" ADD CONSTRAINT "Comments_kweekId_fkey" FOREIGN KEY ("kweekId") REFERENCES "Kweek"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

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

View file

@ -13,34 +13,35 @@ model User {
username String @unique username String @unique
email String @unique email String @unique
password String password String
posts Post[] kweeks Kweek[]
profileImage String? profileImage String?
likedPosts PostLike[] likedKweeks KweekLike[]
likedComments CommentLike[] likedComments CommentLike[]
followers Follows[] @relation("following") followers Follows[] @relation("follower")
following Follows[] @relation("follower") following Follows[] @relation("following")
postComments Comments[] kweeksComments Comments[]
fromNotifications Notifications[] @relation("fromNotifications") fromNotifications Notifications[] @relation("fromNotifications")
toNotifications Notifications[] @relation("toNotifications") toNotifications Notifications[] @relation("toNotifications")
socketId String? socketId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
} }
model Post { model Kweek {
id String @id @default(uuid()) id String @id @default(uuid())
content String content String
authorId String authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade) author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
likes PostLike[] likes KweekLike[]
comments Comments[] comments Comments[]
attachments String[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model PostLike { model KweekLike {
id String @id @default(uuid()) id String @id @default(uuid())
postId String kweekId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) kweek Kweek @relation(fields: [kweekId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@ -71,8 +72,8 @@ model Comments {
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)
postId String kweekId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade) kweek Kweek @relation(fields: [kweekId], references: [id], onDelete: Cascade)
likes CommentLike[] likes CommentLike[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt @default(now()) updatedAt DateTime @updatedAt @default(now())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because it is too large Load diff

View file

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

5
resources/logo-dark.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

5
resources/logo-light.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 36 KiB

View file

@ -1,13 +0,0 @@
/* eslint-disable */
import * as express from 'express'
declare global {
namespace Express {
namespace Multer {
interface File {
location: string
key: string
}
}
}
}

View file

@ -1,45 +0,0 @@
import app from '../../app'
import { expect, describe, beforeAll, afterAll, it } from 'vitest'
import request from 'supertest'
import signUpNewUser from '../utils/create-user'
import deleteUser from '../utils/delete-user'
import type User from 'interfaces/user'
let user: User
describe('POST /post/create', () => {
beforeAll(async () => {
user = await signUpNewUser()
})
afterAll(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await deleteUser(user.username!)
})
it('should respond with 200 status code if the user send the token and the content', async () => {
const response = await request(app)
.post('/post/create')
.send({
content: 'Hello world',
})
.set('Authorization', `Bearer ${user.token ?? ''}`)
.expect(200)
expect(response.body).toEqual(
expect.objectContaining({
id: expect.any(String),
content: expect.any(String),
authorId: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
}),
)
})
it('should respond with 400 status code if the user send no token', async () => {
const response = await request(app).post('/post/create').expect(401)
expect(response.body).toHaveProperty('error')
})
})

View file

@ -1,37 +0,0 @@
import app from '../../app'
import { describe, beforeAll, afterAll, it } from 'vitest'
import request from 'supertest'
import signUpNewUser from '../utils/create-user'
import deleteUser from '../utils/delete-user'
import type User from 'interfaces/user'
let user: User
describe('DELETE /post/delete', () => {
beforeAll(async () => {
user = await signUpNewUser()
})
afterAll(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await deleteUser(user.username!)
})
it('should delete the post successfully', async () => {
const response = await request(app)
.post('/post/create')
.send({
content: 'lorem ipsum',
})
.set('Authorization', `Bearer ${user.token ?? ''}`)
.expect(200)
await request(app)
.post('/post/delete')
.send({
postId: response.body.id,
})
.set('Authorization', `Bearer ${user.token ?? ''}`)
.expect(200)
})
})

View file

@ -1,55 +0,0 @@
import app from '../../app'
import { expect, describe, beforeAll, afterAll, it } from 'vitest'
import request from 'supertest'
import signUpNewUser from '../utils/create-user'
import deleteUser from '../utils/delete-user'
import type User from 'interfaces/user'
let postId: string
let user: User
describe('POST /post/info', () => {
beforeAll(async () => {
user = await signUpNewUser()
const token = user.token ?? ''
const post = await request(app)
.post('/post/create')
.send({
content: 'Hello world',
})
.set('Authorization', `Bearer ${token}`)
.expect(200)
postId = post.body.id
})
afterAll(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await deleteUser(user.username!)
})
it('should respond with 200 status code and return some info about the post', async () => {
const response = await request(app)
.get(`/post/info?id=${postId}`)
.expect(200)
expect(response.body).toEqual(
expect.objectContaining({
id: expect.any(String),
content: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
author: expect.any(Object),
}),
)
})
it('should respond with 400 status code if the post does not exists', async () => {
const response = await request(app).get('/post/info?id=abc').expect(400)
expect(response.body).toHaveProperty('error')
})
})

View file

@ -1,57 +0,0 @@
import app from '../../app'
import { expect, describe, beforeAll, afterAll, it } from 'vitest'
import request from 'supertest'
import signUpNewUser from '../utils/create-user'
import deleteUser from '../utils/delete-user'
import type User from 'interfaces/user'
let user: User
describe('PUT /post/update', () => {
beforeAll(async () => {
user = await signUpNewUser()
})
afterAll(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await deleteUser(user.username!)
})
it('should create a new post and update the content of it', async () => {
const post = await request(app)
.post('/post/create')
.send({
content: 'Lorem',
})
.set('Authorization', `Bearer ${user.token ?? ''}`)
.expect(200)
expect(post.body).toHaveProperty('id')
const fieldsToUpdate = {
postId: post.body.id,
content: 'Lorem ipsum',
}
const response = await request(app)
.put('/post/update')
.send(fieldsToUpdate)
.set('Authorization', `Bearer ${user.token ?? ''}`)
.expect(200)
// Post content should be Lorem Ipsum
if (post.body.content === response.body.content) {
throw new Error("Post didn't update")
}
expect(response.body).toEqual(
expect.objectContaining({
id: expect.any(String),
content: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
author: expect.any(Object),
}),
)
})
})

View file

@ -1,46 +0,0 @@
import app from '../../app'
import deleteUser from '../utils/delete-user'
import { expect, describe, beforeAll, afterAll, it } from 'vitest'
import request from 'supertest'
import signUpNewUser from '../utils/create-user'
import type User from 'interfaces/user'
let user: User
describe('POST /user/auth', () => {
beforeAll(async () => {
user = await signUpNewUser()
})
afterAll(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await deleteUser(user.username!)
})
it('should respond with a error if the user does not exists', async () => {
const response = await request(app)
.post('/user/auth')
.send({ email: 'mm@mm.com', password: 'aa' })
.expect(400)
expect(response.body).toHaveProperty('error')
expect(response.body.error).toBe('Invalid email or password')
})
it('should respond with a error if receive an invalid email or password', async () => {
const response = await request(app)
.post('/user/auth')
.send({ email: user.email, password: 'fake_pass' })
.expect(400)
expect(response.body).toHaveProperty('error')
expect(response.body.error).toBe('Invalid email or password')
})
it('should respond with a error if receive an empty body', async () => {
const response = await request(app).post('/user/auth').send({}).expect(400)
expect(response.body).toHaveProperty('error')
expect(response.body.error).toBe('Missing fields')
})
})

View file

@ -1,20 +0,0 @@
import app from '../../app'
import { describe, beforeAll, it } from 'vitest'
import request from 'supertest'
import signUpNewUser from '../utils/create-user'
import type User from 'interfaces/user'
let user: User
describe('DELETE /user/delete', () => {
beforeAll(async () => {
user = await signUpNewUser()
})
it('should delete the user successfully', async () => {
await request(app)
.post('/user/delete')
.set('Authorization', `Bearer ${user.token ?? ''}`)
.expect(200)
})
})

View file

@ -1,38 +0,0 @@
import app from '../../app'
import { expect, describe, beforeAll, afterAll, it } from 'vitest'
import request from 'supertest'
import deleteUser from '../utils/delete-user'
import signUpNewUser from '../utils/create-user'
import type User from 'interfaces/user'
let user: User
describe('POST /user/info', () => {
beforeAll(async () => {
user = await signUpNewUser()
})
afterAll(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await deleteUser(user.username!)
})
it('should respond with 200 status code and return the user data', async () => {
const response = await request(app)
.get(`/user/info?u=${user.username ?? ''}`)
.expect(200)
expect(response.body).toHaveProperty('profileImage')
expect(response.body).toHaveProperty('displayName')
expect(response.body).toHaveProperty('username')
expect(response.body).toHaveProperty('createdAt')
expect(response.body).toHaveProperty('posts')
expect(response.body).toHaveProperty('likedPosts')
})
it('should respond with 400 status code if the user send no username', async () => {
const response = await request(app).get('/user/info?u=').expect(400)
expect(response.body).toHaveProperty('error')
})
})

View file

@ -1,46 +0,0 @@
import app from '../../app'
import { describe, beforeAll, afterAll, it } from 'vitest'
import request from 'supertest'
import deleteUser from '../utils/delete-user'
import signUpNewUser from '../utils/create-user'
import type User from 'interfaces/user'
let user: User
describe('POST /user/signup', () => {
beforeAll(async () => {
user = await signUpNewUser()
delete user.token
})
afterAll(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await deleteUser(user.username!)
})
it('should respond with a 400 status code if sent any invalid data', async () => {
await request(app)
.post('/user/signup')
.send({
username: 'username12@',
email: user.email,
password: user.password,
})
.expect(400)
})
it('should respond with a 400 status code for an existing username or email', async () => {
await request(app)
.post('/user/signup')
.send({
username: user.username,
email: user.email,
password: user.password,
})
.expect(400)
})
it('should respond with a 400 status code if receive an empty body', async () => {
await request(app).post('/user/signup').send({}).expect(400)
})
})

View file

@ -1,37 +0,0 @@
import app from '../../app'
import request from 'supertest'
import { faker } from '@faker-js/faker'
import type userPayload from '../../interfaces/user'
async function signUpNewUser(): Promise<userPayload> {
// To avoid conflicts with existing usernames or emails
const username = faker.internet.userName({ lastName: 'doe' }).toLowerCase()
const email = faker.internet.email()
const password = faker.internet.password() + '@1'
await request(app)
.post('/user/signup')
.send({
username,
email,
password,
})
.expect(200)
const response = await request(app)
.post('/user/auth')
.send({
email,
password,
})
.expect(200)
return {
username,
email,
password,
token: response.body.token,
}
}
export default signUpNewUser

View file

@ -1,17 +0,0 @@
import prisma from '../../clients/prisma-client'
export default async function deleteUser(username: string) {
await prisma.post.deleteMany({
where: {
author: {
username,
},
},
})
await prisma.user.deleteMany({
where: {
username,
},
})
}

56
src/app.module.ts Normal file
View file

@ -0,0 +1,56 @@
import { FastifyMulterModule } from "@nest-lab/fastify-multer";
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 { 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 { KweeksModule } from "./kweeks/kweeks.module";
import { UserModule } from "./users/users.module";
@Module({
imports: [
UserModule,
AuthModule,
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,
S3Module.forRoot({
config: {
credentials: {
accessKeyId: process.env.MINIO_ROOT_USER, // CHANGE WHEN PRODUCTION TO S3
secretAccessKey: process.env.MINIO_ROOT_PASSWORD,
},
region: "us-east-1",
endpoint: process.env.MINIO_ENDPOINT,
forcePathStyle: true,
},
}),
],
providers: [
{
provide: APP_PIPE,
useClass: ZodValidationPipe,
},
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}

View file

@ -1,49 +0,0 @@
import 'dotenv/config'
import compression from 'compression'
import cors from 'cors'
import express from 'express'
import limiter from 'middlewares/rate-limit'
import morganMiddleware from 'middlewares/morgan'
import router from './routes'
import swaggerUI from 'swagger-ui-express'
import swaggerDocument from 'helpers/parse-swagger'
import swaggerConfig from 'config/swagger'
const app = express()
// TODO: test socket io, emit notifications when create one.
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.use(morganMiddleware)
app.options('*', cors())
app.use(
cors({
credentials: true,
origin: process.env.CLIENT_URL,
methods: ['GET', 'POST', 'PUT'],
optionsSuccessStatus: 200,
}),
)
app.use(express.static('public'))
app.use(limiter)
app.use(router)
app.use(
'/docs',
swaggerUI.serve,
swaggerUI.setup(swaggerDocument, swaggerConfig),
)
app.use(compression({ level: 9 }))
app.get('/', function (_req, res) {
res.redirect('/docs')
})
app.use((_req, res) => {
res.status(404).json({
error: 'Endpoint not found',
})
})
export default app

View file

@ -0,0 +1,35 @@
import {
Body,
Controller,
HttpCode,
Post,
Request,
UseGuards,
} from "@nestjs/common";
import {
ApiOkResponse,
ApiOperation,
ApiTags,
ApiUnauthorizedResponse,
} from "@nestjs/swagger";
import { Public } from "src/decorators/public.decorator";
import { AuthService } from "./auth.service";
import { LoginUserDTO } from "./dto/login.dto";
import { LocalAuthGuard } from "./local-auth.guard";
@ApiTags("Auth")
@Controller("auth")
export class AuthController {
constructor(private authService: AuthService) {}
@Public()
@UseGuards(LocalAuthGuard)
@Post("/")
@ApiOperation({ summary: "Authenticates a user" })
@ApiOkResponse({ status: 200, description: "Authenticated successfully" })
@ApiUnauthorizedResponse({ description: "Wrong username or password" })
@HttpCode(200)
async login(@Request() req, @Body() _: LoginUserDTO) {
return this.authService.login(req.user);
}
}

22
src/auth/auth.module.ts Normal file
View file

@ -0,0 +1,22 @@
import { Module } from "@nestjs/common";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { UserModule } from "src/users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { LocalStrategy } from "./local.strategy";
@Module({
controllers: [AuthController],
imports: [
UserModule,
PassportModule,
JwtModule.register({
secret: process.env.JWT_ACCESS_SECRET,
signOptions: { expiresIn: "1d" }, // TODO: add refresh tokens
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
})
export class AuthModule {}

46
src/auth/auth.service.ts Normal file
View file

@ -0,0 +1,46 @@
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt";
import { UserModel } from "src/users/models/user.model";
import { UserService } from "src/users/users.service";
@Injectable()
export class AuthService {
constructor(
private userService: UserService,
private jwtService: JwtService,
) {}
async validateUser(
username: string,
password: string,
): Promise<UserModel | null> {
const user = await this.userService.auth_search(username);
if (user === undefined) {
return null;
}
const validation = await bcrypt.compare(password, user.password);
if (user && validation) {
const { password, ...result } = user;
return result;
}
return null;
}
async login(user: UserModel): Promise<{ token: string }> {
const payload = {
displayName: user.displayName,
username: user.username,
profileImage: user.profileImage,
sub: user.id,
};
return {
token: this.jwtService.sign(payload),
};
}
}

29
src/auth/dto/login.dto.ts Normal file
View file

@ -0,0 +1,29 @@
import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const LoginUserSchema = z
.object({
username: z
.string({
required_error: "Username is required",
})
.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(),
password: z
.password({
required_error: "Password is required",
})
.min(8)
.max(32)
.atLeastOne("digit")
.atLeastOne("uppercase")
.atLeastOne("lowercase")
.atLeastOne("special")
.transform((value) => value.replace(/\s+/g, "")), // Removes every whitespace
})
.required();
export class LoginUserDTO extends createZodDto(LoginUserSchema) {}

View file

@ -0,0 +1,25 @@
import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { Observable } from "rxjs";
import { IS_PUBLIC_KEY } from "src/decorators/public.decorator";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
constructor(private reflector: Reflector) {
super();
}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}

28
src/auth/jwt.strategy.ts Normal file
View file

@ -0,0 +1,28 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
type Payload = {
displayName: string;
username: string;
sub: string;
};
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_ACCESS_SECRET,
});
}
async validate(payload: Payload) {
return {
displayName: payload.displayName,
username: payload.username,
id: payload.sub,
};
}
}

View file

@ -0,0 +1,5 @@
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}

View file

@ -0,0 +1,20 @@
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { UserModel } from "src/users/models/user.model";
import { AuthService } from "./auth.service";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super();
}
async validate(username: string, password: string): Promise<UserModel> {
const user = await this.authService.validateUser(username, password);
if (!user) {
throw new UnauthorizedException("Wrong username or password");
}
return user;
}
}

View file

@ -1,5 +0,0 @@
import { PrismaClient } from '@prisma/client'
const prisma = new PrismaClient()
export default prisma

View file

@ -1,27 +0,0 @@
import logger from 'helpers/logger'
import { createClient, type RedisClientOptions } from 'redis'
const redisPort = parseInt(process.env.REDIS_PORT ?? '6379', 10)
const redisHost = process.env.REDIS_HOST ?? '127.0.0.1'
const redisPassword = process.env.REDIS_PASSWORD ?? ''
const redisConfig: RedisClientOptions = {
url: `redis://:${redisPassword}@${redisHost}:${redisPort}/0`,
}
const redis = createClient(redisConfig)
redis
.connect()
.then(() => {
logger.info('Successfully connected to Redis')
})
.catch((e: Error) => {
logger.error(`Error while connecting to Redis: ${e.message}`)
})
redis.on('error', async (e: Error) => {
logger.error(`Error in Redis client: ${e.message}`)
})
export default redis

View file

@ -1,24 +0,0 @@
import { S3Client } from '@aws-sdk/client-s3'
import logger from 'helpers/logger'
let s3: S3Client
if (process.env.NODE_ENV === 'development') {
logger.info('Using Localstack services instead of AWS.')
s3 = new S3Client({
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',
},
endpoint: 'http://127.0.0.1:4566', // Uses localstack instead of aws, make sure to create the bucket first with public-read acl
})
} else {
s3 = new S3Client({
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',
},
})
}
export default s3

View file

@ -1,65 +0,0 @@
import multer from 'multer'
import { type Request } from 'express'
import path from 'path'
import s3 from 'clients/s3-client'
import multerS3 from 'multer-s3'
const tempFolder = path.resolve(__dirname, '..', '..', 'temp', 'uploads')
const storageTypes = {
local: multer.diskStorage({
destination: (req: Request, file: Express.Multer.File, callback) => {
callback(null, tempFolder)
},
filename: (req: Request, file: Express.Multer.File, callback) => {
/* eslint-disable */
const folder = req.body.isProfilePicture ? 'profile_images' : 'media'
const fileName: string = `${folder}/${req.res?.locals.user.id}.webp`
callback(null, fileName)
},
}),
s3: multerS3({
s3,
bucket: process.env.AWS_BUCKET ?? '',
contentType: multerS3.AUTO_CONTENT_TYPE,
acl: 'public-read',
key: (req: Request, file: Express.Multer.File, callback) => {
let folder
if (req.body.isProfilePicture === 'true') {
folder = 'profile_images'
} else {
folder = 'media'
}
const fileName: string = `${folder}/${req.res?.locals.user.id}.jpg`
callback(null, fileName)
},
}),
}
const multerConfig = {
dest: tempFolder,
storage: storageTypes.s3,
limits: {
fileSize: 15 * 1024 * 1024, // 1mb
},
fileFilter: (
req: Request,
file: Express.Multer.File,
callback: multer.FileFilterCallback,
) => {
const allowedMimes = ['image/jpeg', 'image/png']
if (allowedMimes.includes(file.mimetype)) {
callback(null, true)
} else {
callback(new Error('Filetype not allowed'))
}
},
}
export default multerConfig

View file

@ -1,9 +0,0 @@
import type { SwaggerUiOptions } from 'swagger-ui-express'
const swaggerConfig: SwaggerUiOptions = {
customCssUrl: '/swagger-ui.css',
customSiteTitle: 'Project Knedita Docs',
customfavIcon: '/favicon.png',
}
export default swaggerConfig

View file

@ -1,22 +0,0 @@
import { Router } from 'express'
// Controllers
import comments from './comments'
// Middlewares
import authenticated from 'middlewares/authenticated'
const commentsRouter = Router()
// GET
commentsRouter.get('/fetch-likes', comments.fetchLikes)
commentsRouter.get('/info', comments.fetch)
// POST
commentsRouter.post('/create', authenticated, comments.create)
commentsRouter.post('/delete', authenticated, comments.delete)
// PUT
commentsRouter.put('/update', authenticated, comments.update)
export default commentsRouter

View file

@ -1,28 +0,0 @@
import comment from 'services/comments'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function commentCreateController(
req: Request,
res: Response,
): Promise<void> {
const { content, postId } = req.body
const id = res.locals.user.id
if (postId === undefined) {
badRequest(res, 'Expected post id')
return
}
if (content === undefined) {
badRequest(res, 'Expected comment content')
return
}
const result = await comment.create(postId, content, id)
handleResponse(res, result)
}
export default commentCreateController

View file

@ -1,23 +0,0 @@
import comment from 'services/comments'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function commentDeleteController(
req: Request,
res: Response,
): Promise<void> {
const { commentId } = req.body
const id = res.locals.user.id
if (commentId === undefined) {
badRequest(res, 'Expected comment id')
return
}
const result = await comment.delete(commentId, id)
handleResponse(res, result)
}
export default commentDeleteController

View file

@ -1,22 +0,0 @@
import comment from 'services/comments'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function commentFetchController(
req: Request,
res: Response,
): Promise<void> {
const commentId = req.query.id as string
if (commentId === undefined) {
badRequest(res, 'Expected comment id')
return
}
const result = await comment.fetch(commentId)
handleResponse(res, result)
}
export default commentFetchController

View file

@ -1,22 +0,0 @@
import comment from 'services/comments'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function commentFetchLikesController(
req: Request,
res: Response,
): Promise<void> {
const commentId = req.query.id as string
if (commentId === undefined) {
badRequest(res, 'Expected comment id')
return
}
const result = await comment.fetchLikes(commentId)
handleResponse(res, result)
}
export default commentFetchLikesController

View file

@ -1,15 +0,0 @@
import commentCreateController from './create'
import commentDeleteController from './delete'
import commentFetchController from './fetch-info'
import commentFetchLikesController from './fetch-likes'
import commentUpdateController from './update'
const comments = {
create: commentCreateController,
delete: commentDeleteController,
fetch: commentFetchController,
fetchLikes: commentFetchLikesController,
update: commentUpdateController,
} as const
export default comments

View file

@ -1,28 +0,0 @@
import comment from 'services/comments'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function commentUpdateController(
req: Request,
res: Response,
): Promise<void> {
const { commentId, content } = req.body
const id = res.locals.user.id
if (commentId === undefined) {
badRequest(res, 'Expected comment content')
return
}
if (content === undefined) {
badRequest(res, 'Expected content to update')
return
}
const result = await comment.update(content, id, commentId)
handleResponse(res, result)
}
export default commentUpdateController

View file

@ -1,22 +0,0 @@
import { Router } from 'express'
// Controllers
import post from './posts'
// Middlewares
import authenticated from 'middlewares/authenticated'
const postsRouter = Router()
// GET
postsRouter.get('/fetch-likes', post.fetchLikes)
postsRouter.get('/info', post.fetch)
// POST
postsRouter.post('/create', authenticated, post.create)
postsRouter.post('/delete', authenticated, post.delete)
// PUT
postsRouter.put('/update', authenticated, post.update)
export default postsRouter

View file

@ -1,23 +0,0 @@
import post from 'services/posts'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function postCreateController(
req: Request,
res: Response,
): Promise<void> {
const { content } = req.body
const id = res.locals.user.id
if (content === undefined) {
badRequest(res, 'Expected post content')
return
}
const result = await post.create(content, id)
handleResponse(res, result)
}
export default postCreateController

View file

@ -1,23 +0,0 @@
import post from 'services/posts'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function postDeleteController(
req: Request,
res: Response,
): Promise<void> {
const userId = res.locals.user.id
const postId = req.body.postId
if (postId === undefined) {
badRequest(res, 'Missing post id')
return
}
const result = await post.delete(postId, userId)
handleResponse(res, result)
}
export default postDeleteController

View file

@ -1,22 +0,0 @@
import post from 'services/posts'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function postFetchInfoController(
req: Request,
res: Response,
): Promise<void> {
const id = req.query.id as string
if (id === undefined) {
badRequest(res, 'Missing post id')
return
}
const result = await post.fetch(id)
handleResponse(res, result)
}
export default postFetchInfoController

View file

@ -1,22 +0,0 @@
import post from 'services/posts'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function postFetchLikesController(
req: Request,
res: Response,
): Promise<void> {
const id = req.query.id as string
if (id === undefined) {
badRequest(res, 'Missing post id')
return
}
const result = await post.fetchLikes(id)
handleResponse(res, result)
}
export default postFetchLikesController

View file

@ -1,15 +0,0 @@
import postCreateController from './create'
import postDeleteController from './delete'
import postFetchInfoController from './fetch-info'
import postUpdateController from './update'
import postFetchLikesController from './fetch-likes'
const post = {
create: postCreateController,
delete: postDeleteController,
fetch: postFetchInfoController,
fetchLikes: postFetchLikesController,
update: postUpdateController,
} as const
export default post

View file

@ -1,17 +0,0 @@
import post from 'services/posts'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function postUpdateController(
req: Request,
res: Response,
): Promise<void> {
const { postId, content } = req.body
const userId = res.locals.user.id
const result = await post.update(postId, content, userId)
handleResponse(res, result)
}
export default postUpdateController

View file

@ -1,37 +0,0 @@
import { Router } from 'express'
// Controllers
import user from './users'
// Middlewares
import authenticated from 'middlewares/authenticated'
import uploadFile from 'middlewares/upload-image'
const usersRouter = Router()
// GET
usersRouter.get('/fetch-posts', user.fetchPosts)
usersRouter.get('/info', user.fetchInfo)
usersRouter.get('/search', user.searchUser)
// POST
usersRouter.post('/auth', user.auth)
usersRouter.post('/delete', authenticated, user.delete)
usersRouter.post('/me', authenticated, user.fetchUser)
usersRouter.post('/follow-user', authenticated, user.follow)
usersRouter.post('/like-comment', authenticated, user.likeComment)
usersRouter.post('/like-post', authenticated, user.likePost)
usersRouter.post('/signup', user.signup)
// PUT
usersRouter.put(
'/profile-picture/upload',
authenticated,
uploadFile,
user.uploadPicture,
)
usersRouter.put('/update-email', authenticated, user.updateEmail)
usersRouter.put('/update-name', authenticated, user.updateName)
usersRouter.put('/update-password', authenticated, user.updatePassword)
export default usersRouter

View file

@ -1,13 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userAuthController(req: Request, res: Response): Promise<void> {
const { email, password } = req.body
const result = await user.auth({ email, password })
handleResponse(res, result)
}
export default userAuthController

View file

@ -1,15 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userDeleteController(
req: Request,
res: Response,
): Promise<void> {
const userId = res.locals.user.id
const result = await user.delete(userId)
handleResponse(res, result)
}
export default userDeleteController

View file

@ -1,22 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function userFetchInfoController(
req: Request,
res: Response,
): Promise<void> {
const username = req.query.u as string
if (username === undefined) {
badRequest(res, 'Missing username')
return
}
const result = await user.fetchInfo(username.toLowerCase())
handleResponse(res, result)
}
export default userFetchInfoController

View file

@ -1,22 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function userFetchPostsController(
req: Request,
res: Response,
): Promise<void> {
const username = req.query.u as string
if (username === undefined) {
badRequest(res, 'Missing username')
return
}
const result = await user.fetchPosts(username)
handleResponse(res, result)
}
export default userFetchPostsController

View file

@ -1,22 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function userFetchUserController(
req: Request,
res: Response,
): Promise<void> {
const id = res.locals.user.id
if (id === undefined) {
badRequest(res, 'Missing id')
return
}
const result = await user.fetchUser(id)
handleResponse(res, result)
}
export default userFetchUserController

View file

@ -1,17 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userFollowController(
req: Request,
res: Response,
): Promise<void> {
const userId = res.locals.user.id
const { userToFollow } = req.body
const result = await user.follow(userId, userToFollow)
handleResponse(res, result)
}
export default userFollowController

View file

@ -1,33 +0,0 @@
import userAuthController from './auth'
import userDeleteController from './delete'
import userFollowController from './follow-user'
import userFetchInfoController from './fetch-info'
import userFetchPostsController from './fetch-posts'
import userFetchUserController from './fetch-user'
import userLikeCommentController from './like-comment'
import userLikePostController from './like-post'
import userSearchController from './search-user'
import userSignupController from './signup'
import userUpdateEmailController from './update-email'
import userUpdateNameController from './update-name'
import userUpdatePasswordController from './update-password'
import userUploadPictureController from './upload-picture'
const user = {
auth: userAuthController,
delete: userDeleteController,
fetchInfo: userFetchInfoController,
fetchPosts: userFetchPostsController,
fetchUser: userFetchUserController,
follow: userFollowController,
likeComment: userLikeCommentController,
likePost: userLikePostController,
searchUser: userSearchController,
signup: userSignupController,
updateEmail: userUpdateEmailController,
updateName: userUpdateNameController,
updatePassword: userUpdatePasswordController,
uploadPicture: userUploadPictureController,
} as const
export default user

View file

@ -1,17 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userLikeCommentController(
req: Request,
res: Response,
): Promise<void> {
const userId = res.locals.user.id
const { commentId } = req.body
const result = await user.likeComment(commentId, userId)
handleResponse(res, result)
}
export default userLikeCommentController

View file

@ -1,17 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userLikePostController(
req: Request,
res: Response,
): Promise<void> {
const userId = res.locals.user.id
const { postId } = req.body
const result = await user.likePost(postId, userId)
handleResponse(res, result)
}
export default userLikePostController

View file

@ -1,21 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
async function userSearchController(
req: Request,
res: Response,
): Promise<void> {
const username = req.query.u as string
if (username === undefined) {
badRequest(res, 'Missing username')
return
}
const result = await user.searchUser(username)
res.json(result)
}
export default userSearchController

View file

@ -1,16 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userSignupController(
req: Request,
res: Response,
): Promise<void> {
const { username, email, password } = req.body
const result = await user.signup({ username, email, password })
handleResponse(res, result)
}
export default userSignupController

View file

@ -1,17 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userUpdateEmailController(
req: Request,
res: Response,
): Promise<void> {
const { email } = req.body
const id = res.locals.user.id
const result = await user.updateEmail({ id, email })
handleResponse(res, result)
}
export default userUpdateEmailController

View file

@ -1,17 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userUpdateNameController(
req: Request,
res: Response,
): Promise<void> {
const { displayName, username } = req.body
const id = res.locals.user.id
const result = await user.updateName({ id, displayName, username })
handleResponse(res, result)
}
export default userUpdateNameController

View file

@ -1,17 +0,0 @@
import user from 'services/users'
import type { Request, Response } from 'express'
import handleResponse from 'helpers/handle-response'
async function userUpdatePasswordController(
req: Request,
res: Response,
): Promise<void> {
const { currentPassword, newPassword } = req.body
const id = res.locals.user.id
const result = await user.updatePassword(id, currentPassword, newPassword)
handleResponse(res, result)
}
export default userUpdatePasswordController

View file

@ -1,33 +0,0 @@
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import user from 'services/users'
import type { Request, Response } from 'express'
import { badRequest } from 'helpers/http-errors'
import handleResponse from 'helpers/handle-response'
async function userUploadPictureController(
req: Request,
res: Response,
): Promise<void> {
if (req.file === undefined) {
badRequest(res, 'Expected a JPG or PNG file')
return
}
const userId = res.locals.user.id
let url: string
if (process.env.NODE_ENV === 'development') {
url = `http://${
process.env.AWS_BUCKET ?? ''
}.s3.localhost.localstack.cloud:4566/${req.file.key}`
} else {
url = req.file.location
}
const result = await user.uploadPicture(userId, url)
handleResponse(res, result)
}
export default userUploadPictureController

View file

@ -0,0 +1,27 @@
// Thanks sandeepsuvit @ https://github.com/nestjs/swagger/issues/417
import { ApiBody } from "@nestjs/swagger";
export const ApiCreateKweek =
(fieldName: string): MethodDecorator =>
// biome-ignore lint/suspicious/noExplicitAny: idk typing for target
(target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
ApiBody({
required: true,
schema: {
type: "object",
properties: {
content: {
type: "string",
},
[fieldName]: {
type: "array",
items: {
type: "string",
format: "binary",
},
},
},
},
})(target, propertyKey, descriptor);
};

View file

@ -0,0 +1,4 @@
import { SetMetadata } from "@nestjs/common";
export const IS_PUBLIC_KEY = "isPublic";
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View file

@ -1,43 +0,0 @@
import sharp from 'sharp'
import s3 from 'clients/s3-client'
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'
export default async function compressImage(
imageName: string,
isProfilePicture: string,
): Promise<Error | Record<never, never>> {
// Get file from s3
const { Body } = await s3.send(
new GetObjectCommand({
Bucket: process.env.AWS_BUCKET ?? '',
Key: imageName,
}),
)
const imageBuffer = await Body?.transformToByteArray()
const compressedImageBuffer = await sharp(imageBuffer)
.resize(
isProfilePicture === 'true' ? 200 : undefined,
isProfilePicture === 'true' ? 200 : undefined,
)
.jpeg({ quality: 65 })
.toBuffer()
// Send file back
const params = {
Bucket: process.env.AWS_BUCKET ?? '',
Key: imageName,
Body: compressedImageBuffer,
ContentType: 'image/jpeg',
ContentDisposition: 'inline',
}
const { ETag } = await s3.send(new PutObjectCommand(params))
if (ETag !== null) {
return {}
} else {
return new Error('Error while compressing image')
}
}

View file

@ -1,10 +0,0 @@
import { type Response } from 'express'
import { badRequest } from './http-errors'
export default function handleResponse(res: Response, result: any): void {
if (result instanceof Error) {
badRequest(res, result.message)
} else {
res.json(result)
}
}

View file

@ -1,28 +0,0 @@
import { type Response } from 'express'
const sendErrorResponse = (
res: Response,
status: number,
message: string,
): void => {
res.status(status).json({ error: message })
}
export const badRequest = (res: Response, message = 'Bad Request'): void => {
sendErrorResponse(res, 400, message)
}
export const unauthorized = (res: Response, message = 'Unauthorized'): void => {
sendErrorResponse(res, 401, message)
}
export const forbidden = (res: Response, message = 'Forbidden'): void => {
sendErrorResponse(res, 403, message)
}
export const internalServerError = (
res: Response,
message = 'Internal Server Error',
): void => {
sendErrorResponse(res, 500, message)
}

View file

@ -1,52 +0,0 @@
import winston from 'winston'
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
}
const level = (): string => {
// eslint-disable-next-line @typescript-eslint/strict-boolean-expressions, @typescript-eslint/prefer-nullish-coalescing
const env = process.env.NODE_ENV || 'development'
const isDevelopment = env === 'development'
return isDevelopment ? 'debug' : 'warn'
}
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'white',
}
winston.addColors(colors)
const format = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
winston.format.colorize({ all: true }),
winston.format.printf(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
info => `${info.timestamp} ${info.level}: ${info.message}`,
),
)
const transports = [
new winston.transports.Console(),
new winston.transports.File({
filename: 'logs/error.log',
level: 'error',
}),
]
const logger = winston.createLogger({
level: level(),
levels,
format,
transports,
})
export default logger

View file

@ -1,49 +0,0 @@
import { type NotificationType } from '@prisma/client'
import prisma from 'clients/prisma-client'
export async function createNotification(
fromUserId: string,
toUserId: string,
content: string,
type: NotificationType,
): Promise<Record<never, never> | Error> {
try {
await prisma.notifications.create({
data: {
type,
fromUserId,
toUserId,
content,
},
include: {
fromUser: {
select: {
id: true,
displayName: true,
username: true,
profileImage: true,
},
},
},
})
return {}
} catch (_) {
return new Error('Error while creating notification')
}
}
export async function countNotifications(
toUserId: string,
): Promise<number | Error> {
try {
const count = await prisma.notifications.count({
where: {
toUserId,
},
})
return count
} catch (_) {
return new Error('Error while counting user notifications')
}
}

View file

@ -1,7 +0,0 @@
import { parse } from 'yaml'
import { readFileSync } from 'fs'
const swaggerConfigFile = readFileSync('./swagger.yaml', 'utf-8')
const swaggerDocument = parse(swaggerConfigFile)
export default swaggerDocument

View file

@ -1,7 +0,0 @@
interface jwtPayload {
id: string
iat: number
exp: number
}
export default jwtPayload

View file

@ -1,13 +0,0 @@
interface User {
id?: string
displayName?: string | null
username?: string
email?: string
password?: string
profileImage?: string | null
createdAt?: Date
token?: string
socketId?: string
}
export default User

View file

@ -0,0 +1,11 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "src/services/prisma/prisma.service";
import { S3Service } from "src/services/s3/s3.service";
@Injectable()
export class CommentsService {
constructor(
private readonly prisma: PrismaService,
private readonly s3: S3Service,
) {}
}

View file

@ -0,0 +1,11 @@
import { createZodDto } from "nestjs-zod";
import { z } from "nestjs-zod/z";
export const UpdateKweekSchema = z
.object({
id: z.string().toLowerCase().describe("New username - optional"),
content: z.string({ required_error: "Content is required" }).max(300),
})
.required();
export class UpdateKweekDTO extends createZodDto(UpdateKweekSchema) {}

View file

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

Some files were not shown because too many files have changed in this diff Show more