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
node_modules/
.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>
# 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_PASSWORD=<placehoder>
REDIS_PASSWORD=<same_as_defined_in_docker_compose_file>
# Express
# Fastify
SERVER_PORT=<placeholder>
CLIENT_URL=<placeholder>
SERVER_HOST=<placeholder>
# Security
JWT_ACCESS_SECRET=<placeholder>
# Localstack - The data can be fake (Change this to real data on production)
SERVICES=s3
AWS_ACCESS_KEY_ID=<placeholder>
AWS_SECRET_ACCESS_KEY=<placeholder>
AWS_DEFAULT_REGION=us-east-1
AWS_DEFAULT_OUTPUT=json
# Minio
MINIO_ROOT_USER=<username>
MINIO_ROOT_PASSWORD=<password_more_or_equal_to_8_characters>
MINIO_DEFAULT_BUCKETS=<bucket_name>
MINIO_ENDPOINT=<url>

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 .

47
.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
node_modules
.env
prisma/*.db
.DS_Store
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
. "$(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
}

36
.swcrc
View file

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

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 /app
WORKDIR /usr/src/app
COPY package*.json ./
COPY 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
COPY --chown=node:node package*.json ./
COPY --chown=node:node prisma ./prisma/
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
Copyright (c) 2023 CookieDasora
Copyright (c) 2024 CookieDasora
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View file

@ -1,30 +1,73 @@
<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>
## Stack
A simple RESTful API made with **NestJS** and **Fastify**.
**Client**: NextJS, TailwindCSS and Radix UI Icons.
You can find the front-end [here](https://github.com/CookieDasora/project-knedita-client)
### 🚀 Preparing the environment
**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 ✅
- Add post attachments
- Create/update/delete Users ✅
- Password recuperation
- Two step verification
- Able to choose a profile picture✅
- Probably gonna use LocalStack to mock Amazon S3✅
- Image compression ✅
- Following/unfollowing features ✅
- Like posts ✅
- Authentication ✅
- Add more verification (like, if the password is too short) ✅
- Set display name ✅
- Add rate limit ✅
```bash
$ npm 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.
You can find the templates for those files on `.env.example` and `docker.env.example`.
To run the necessary services you can execute the following command:
```bash
$ npm run docker:db
```
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

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 = {
extends: ['@commitlint/config-conventional'],
}
extends: ["@commitlint/config-conventional"],
};

View file

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

View file

@ -4,23 +4,11 @@ networks:
localstack-net:
name: localstack-net
driver: bridge
minio_network:
name: minio_network
driver: bridge
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:
image: postgres:alpine
restart: unless-stopped
@ -32,20 +20,18 @@ services:
volumes:
- postgres:/var/lib/postgresql/data
localstack:
image: localstack/localstack
container_name: localstack_main
minio:
image: bitnami/minio
restart: unless-stopped
networks:
- localstack-net
ports:
- 4566:4566
- 4572:4572
- '9000:9000'
- '9001:9001'
networks:
- minio_network
volumes:
- 'minio_data:/data'
env_file:
- docker.env
volumes:
- localstack:/data
- '/var/run/docker.sock:/var/run/docker.sock'
redis:
image: redis:alpine
@ -57,9 +43,25 @@ services:
volumes:
- 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:
postgres:
name: backend-db
redis:
driver: local
localstack:
minio_data:
driver: local

View file

@ -5,26 +5,27 @@ NODE_ENV=production
POSTGRES_DB=<placeholder>
POSTGRES_USER=<placeholder>
POSTGRES_PASSWORD=<placeholder>
# Use this hostname on Docker
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
# Use this hostname on Docker
REDIS_HOST=redis
REDIS_PORT=<placeholder>
REDIS_PASSWORD=<placeholder>
REDIS_PASSWORD=<same_as_defined_in_docker_compose_file>
# Express
# Fastify
SERVER_PORT=<placeholder>
SERVER_HOST=<placeholder>
# Security
JWT_ACCESS_SECRET=<placeholder>
# Localstack - The data can be fake
SERVICES=s3
AWS_ACCESS_KEY_ID=<placeholder>
AWS_SECRET_ACCESS_KEY=<placeholder>
AWS_DEFAULT_REGION=us-east-1
AWS_DEFAULT_OUTPUT=json
AWS_BUCKET=<placeholder>
# Minio
MINIO_ROOT_USER=<username>
MINIO_ROOT_PASSWORD=<password_more_or_equal_to_8_characters>
MINIO_DEFAULT_BUCKETS=<bucket_name>
MINIO_ENDPOINT=<url>

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

14989
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,94 +1,116 @@
{
"name": "social-media-app",
"version": "0.0.1",
"description": "A social media",
"name": "project-knedita",
"version": "0.1.0",
"description": "A open-source social media",
"author": "hknsh",
"license": "MIT",
"scripts": {
"build": "swc src -d dist",
"dev:start": "ts-node-dev -r tsconfig-paths/register --transpile-only --respawn src/server.ts",
"build": "nest build",
"dev:start": "nest start --watch",
"dev:debug": "nest start --debug --watch",
"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",
"docker:seed": "docker exec -it api npm run prisma:seed",
"lint": "eslint --ignore-path .eslintignore --ext .js,.ts .",
"lint": "npx @biomejs/biome check --apply .",
"migrate:deploy": "prisma migrate deploy",
"migrate:dev": "prisma migrate dev",
"migrate:dev:create": "prisma migrate dev --create-only",
"migrate:reset": "prisma migrate reset",
"prepare": "husky install",
"prisma:generate": "npx prisma generate",
"prisma:seed": "prisma db seed",
"prisma:studio": "npx prisma studio",
"prod:start": "npx prisma migrate deploy && pm2-runtime start dist/server.js",
"test": "vitest run"
},
"ts-standard": {
"project": "tsconfig.json",
"ignore": [
"prisma/*",
"dist"
]
},
"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"
"prepare": "husky",
"prod": "npm run migrate:deploy && node dist/main",
"start": "nest start",
"test": "jest",
"test:cov": "jest --coverage",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:watch": "jest --watch"
},
"dependencies": {
"@prisma/client": "^5.4.1",
"aws-sdk": "^2.1545.0",
"bcrypt": "^5.1.0",
"compression": "^1.7.4",
"cors": "^2.8.5",
"dotenv": "^16.3.2",
"express": "^4.18.2",
"express-rate-limit": "^7.1.1",
"@aws-sdk/client-s3": "^3.502.0",
"@fastify/helmet": "^11.1.1",
"@fastify/multipart": "^8.1.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",
"@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",
"jsonwebtoken": "^9.0.0",
"morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"multer-s3": "^3.0.1",
"rate-limit-redis": "^4.0.0",
"redis": "^4.6.7",
"sharp": "^0.32.3",
"socket.io": "^4.7.2",
"swagger-ui-express": "^5.0.0",
"validator": "^13.9.0",
"winston": "^3.11.0",
"yaml": "^2.3.4"
"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.1.13",
"rxjs": "^7.8.1",
"sharp": "^0.33.2"
},
"devDependencies": {
"@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,37 +13,38 @@ model User {
username String @unique
email String @unique
password String
posts Post[]
kweeks Kweek[]
profileImage String?
likedPosts PostLike[]
likedKweeks KweekLike[]
likedComments CommentLike[]
followers Follows[] @relation("following")
following Follows[] @relation("follower")
postComments Comments[]
followers Follows[] @relation("follower")
following Follows[] @relation("following")
kweeksComments Comments[]
fromNotifications Notifications[] @relation("fromNotifications")
toNotifications Notifications[] @relation("toNotifications")
socketId String?
createdAt DateTime @default(now())
}
model Post {
id String @id @default(uuid())
content String
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
likes PostLike[]
comments Comments[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
model Kweek {
id String @id @default(uuid())
content String
authorId String
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
likes KweekLike[]
comments Comments[]
attachments String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PostLike {
id String @id @default(uuid())
postId String
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
model KweekLike {
id String @id @default(uuid())
kweekId String
kweek Kweek @relation(fields: [kweekId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
// I should join these two up? Yeah, but I will not do it since it didn't work on the first time.
@ -71,8 +72,8 @@ model Comments {
content String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
postId String
post Post @relation(fields: [postId], 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())
@ -92,4 +93,4 @@ model Notifications {
enum NotificationType {
WARNING
INFO
}
}

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