diff --git a/.eslintrc.js b/.eslintrc.js index f44c7a1df..3ff8c2346 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,10 +1,10 @@ module.exports = { extends: '@mate-academy/eslint-config', env: { - jest: true + jest: true, }, rules: { - 'no-proto': 0 + 'no-proto': 0, }, - plugins: ['jest'] + plugins: ['jest'], }; diff --git a/.gitignore b/.gitignore index bd6a178a8..555f1d534 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,10 @@ node_modules # MacOS .DS_Store + +# env files +*.env +.env* + +.env +pnpm-lock.yaml diff --git a/package.json b/package.json index 4f64337fe..bd1e8fcaa 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "init": "mate-scripts init", "start": "node src/index.js", + "dev": "tsx --watch --env-file=.env --inspect=0.0.0.0 src/index.ts", "lint": "npm run format && mate-scripts lint", "format": "prettier --ignore-path .prettierignore --write './src/**/*.{js,ts}'", "test:only": "mate-scripts test", @@ -18,13 +19,32 @@ "devDependencies": { "@mate-academy/eslint-config": "latest", "@mate-academy/scripts": "^1.8.6", + "@types/cookie-parser": "^1.4.8", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.1", + "@types/jsonwebtoken": "^9.0.9", + "@types/node": "^22.15.3", + "@types/ws": "^8.18.1", "eslint": "^8.57.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-node": "^11.1.0", "jest": "^29.7.0", - "prettier": "^3.3.2" + "prettier": "^3.3.2", + "prisma": "^6.7.0", + "tsx": "^4.19.4", + "typescript": "^5.8.3" }, "mateAcademy": { - "projectType": "javascript" + "projectType": "typescript" + }, + "dependencies": { + "@prisma/client": "6.7.0", + "close-with-grace": "^2.2.0", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "express": "^5.1.0", + "jsonwebtoken": "^9.0.2", + "ws": "^8.18.2", + "zod": "^3.24.4" } } diff --git a/prisma/migrations/20250507042010_init/migration.sql b/prisma/migrations/20250507042010_init/migration.sql new file mode 100644 index 000000000..2e4ab9ef5 --- /dev/null +++ b/prisma/migrations/20250507042010_init/migration.sql @@ -0,0 +1,98 @@ +-- CreateTable +CREATE TABLE "users" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "tokens" ( + "id" UUID NOT NULL, + "token" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_id" UUID NOT NULL, + + CONSTRAINT "tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rooms" ( + "id" UUID NOT NULL, + "name" TEXT NOT NULL, + "updated_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "creator_id" UUID NOT NULL, + + CONSTRAINT "rooms_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "members" ( + "id" SERIAL NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "room_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + + CONSTRAINT "members_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "messages" ( + "id" SERIAL NOT NULL, + "text" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "room_id" UUID NOT NULL, + "author_id" UUID NOT NULL, + + CONSTRAINT "messages_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_name_key" ON "users"("name"); + +-- CreateIndex +CREATE INDEX "users_name_idx" ON "users"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "tokens_token_key" ON "tokens"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "tokens_user_id_key" ON "tokens"("user_id"); + +-- CreateIndex +CREATE INDEX "tokens_token_idx" ON "tokens"("token"); + +-- CreateIndex +CREATE UNIQUE INDEX "rooms_name_key" ON "rooms"("name"); + +-- CreateIndex +CREATE INDEX "rooms_name_idx" ON "rooms"("name"); + +-- CreateIndex +CREATE INDEX "members_room_id_user_id_idx" ON "members"("room_id", "user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "members_room_id_user_id_key" ON "members"("room_id", "user_id"); + +-- CreateIndex +CREATE INDEX "messages_room_id_author_id_idx" ON "messages"("room_id", "author_id"); + +-- AddForeignKey +ALTER TABLE "tokens" ADD CONSTRAINT "tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "rooms" ADD CONSTRAINT "rooms_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_room_id_fkey" FOREIGN KEY ("room_id") REFERENCES "rooms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "members" ADD CONSTRAINT "members_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "messages" ADD CONSTRAINT "messages_room_id_fkey" FOREIGN KEY ("room_id") REFERENCES "rooms"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "messages" ADD CONSTRAINT "messages_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 000000000..044d57cdb --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 000000000..1d60c4ed7 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,80 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DB_URL") +} + +model User { + id String @id @default(uuid()) @db.Uuid + name String @unique + createdAt DateTime @default(now()) @map("created_at") + + token Token? + messages Message[] + memberships Member[] + createdRooms Room[] + + @@map("users") + @@index([name]) +} + +model Token { + id String @id @default(uuid()) @db.Uuid + token String @unique + createdAt DateTime @default(now()) @map("created_at") + + userId String @unique @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("tokens") + @@index([token]) +} + +model Room { + id String @id @default(uuid()) @db.Uuid + name String @unique + updatedAt DateTime? @map("updated_at") + createdAt DateTime @default(now()) @map("created_at") + + messages Message[] + members Member[] + + creatorId String @map("creator_id") @db.Uuid + creator User @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + @@map("rooms") + @@index([name]) +} + +model Member { + id Int @id @default(autoincrement()) + createdAt DateTime @default(now()) @map("created_at") + + roomId String @map("room_id") @db.Uuid + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + + userId String @map("user_id") @db.Uuid + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([roomId, userId]) + @@map("members") + @@index([roomId, userId]) +} + +model Message { + id Int @id @default(autoincrement()) + text String + createdAt DateTime @default(now()) @map("created_at") + + roomId String @map("room_id") @db.Uuid + room Room @relation(fields: [roomId], references: [id], onDelete: Cascade) + + authorId String @map("author_id") @db.Uuid + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + + @@map("messages") + @@index([roomId, authorId]) +} diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts new file mode 100644 index 000000000..e5a450000 --- /dev/null +++ b/src/controllers/auth.controller.ts @@ -0,0 +1,55 @@ +import { Request, Response } from 'express'; + +import { AuthData } from '../types/AuthData'; +import { authService } from '../services/auth.service'; +import { tokenService } from '../services/token.service'; + +import { NameSchema } from '../schemas/name.schema'; +import { ResponseBody } from '../types/ResponseBody'; +import { NormalizedUser } from '../types/NormalizedUser'; +import { RefreshTokenSchema } from '../schemas/token.schema'; + +type AuthResponse = Response< + ResponseBody<{ user: NormalizedUser; accessToken: string }> +>; + +class AuthController { + register = async (req: Request<{}, {}, NameSchema>, res: AuthResponse) => { + const { name } = req.body; + const authData = await authService.register(name); + + await this.sendAuthentication(res, authData); + }; + + refreshToken = async ( + req: Request & { cookies: RefreshTokenSchema }, + res: AuthResponse, + ) => { + const { refreshToken } = req.cookies; + const authData = await tokenService.refresh(refreshToken); + + await this.sendAuthentication(res, authData); + }; + + private sendAuthentication = async ( + res: AuthResponse, + { refreshToken, normalizedUser: user, ...otherData }: AuthData, + ) => { + res.cookie('refreshToken', refreshToken, { + maxAge: 7 * 24 * 60 * 60 * 1000, + httpOnly: true, + sameSite: 'none', + secure: true, + }); + + res.status(201).json({ + message: 'OK', + data: { + user, + ...otherData, + }, + }); + }; +} + +export const authController = new AuthController(); diff --git a/src/controllers/member.controller.ts b/src/controllers/member.controller.ts new file mode 100644 index 000000000..8a92bb5ee --- /dev/null +++ b/src/controllers/member.controller.ts @@ -0,0 +1,32 @@ +import { Request, Response } from 'express'; +import { memberService } from '../services/member.service'; + +import { ResponseBody } from '../types/ResponseBody'; +import { RoomIdSchema } from '../schemas/roomId.schema'; +import { NormalizedUser } from '../types/NormalizedUser'; + +class MemberController { + join = async ( + req: Request, + res: Response>, + ) => { + const { roomId } = req.params; + const { id: userId } = req.user!; + const normalizedRoom = await memberService.join(roomId, userId); + + res.status(200).json({ + message: 'OK', + data: normalizedRoom, + }); + }; + + leave = async (req: Request, res: Response) => { + const { roomId } = req.params; + const { id: userId } = req.user!; + + await memberService.leave(roomId, userId); + res.sendStatus(204); + }; +} + +export const memberController = new MemberController(); diff --git a/src/controllers/message.controller.ts b/src/controllers/message.controller.ts new file mode 100644 index 000000000..a5a9e3823 --- /dev/null +++ b/src/controllers/message.controller.ts @@ -0,0 +1,37 @@ +import { Request, Response } from 'express'; +import { messageService } from '../services/message.service'; + +import { TextSchema } from '../schemas/text.schema'; +import { RoomIdSchema } from '../schemas/roomId.schema'; + +import { ResponseBody } from '../types/ResponseBody'; +import { NormalizedUser } from '../types/NormalizedUser'; +import { MessagePreview } from '../types/MessagePreview'; + +class MessageController { + getAll = async ( + req: Request, + res: Response>, + ) => { + const { roomId } = req.params; + const { id: userId } = req.user!; + const previews = await messageService.getAll(roomId, userId); + + res.json({ message: 'OK', data: previews }); + }; + + create = async ( + req: Request, + res: Response>, + ) => { + const { text } = req.body; + const { roomId } = req.params; + const { id: authorId } = req.user!; + + const preview = await messageService.create(roomId, authorId, text); + + res.status(201).json({ message: 'OK', data: preview }); + }; +} + +export const messageController = new MessageController(); diff --git a/src/controllers/room.controller.ts b/src/controllers/room.controller.ts new file mode 100644 index 000000000..d8587a192 --- /dev/null +++ b/src/controllers/room.controller.ts @@ -0,0 +1,91 @@ +import { Request, RequestHandler, Response } from 'express'; + +import { db } from '../utils/db'; +import { roomService } from '../services/room.service'; +import { memberService } from '../services/member.service'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +import { NameSchema } from '../schemas/name.schema'; +import { RoomIdSchema } from '../schemas/roomId.schema'; + +import { RoomPreview } from '../types/RoomPreview'; +import { ResponseBody } from '../types/ResponseBody'; +import { RoomWithRole } from '../types/RoomWithRole'; + +class RoomController { + getSummary = async ( + req: Request, + res: Response>, + ) => { + const { id: userId } = req.user!; + const preview = await roomService.getSummary(userId); + + res.json({ + message: 'OK', + data: preview, + }); + }; + + getWithRole = async ( + req: Request, + res: Response>, + ) => { + const { id: userId } = req.user!; + const { roomId: id } = req.params; + + const roomWithRole = await roomService.getWithRole(id, userId); + + res.json({ + message: 'OK', + data: roomWithRole, + }); + }; + + create = async ( + req: Request<{}, {}, NameSchema>, + res: Response>, + ) => { + const { name } = req.body; + const { id: userId } = req.user!; + + const preview = await db.$transaction( + async (tx: PrismaTransactionClient): Promise => { + const preview = await roomService.create(name, userId, tx); + await memberService.create(preview.id, userId, tx); + + return preview; + }, + ); + + res.status(201).json({ + message: 'OK', + data: preview, + }); + }; + + delete = async (req: Request, res: Response) => { + const { id: userId } = req.user!; + const { roomId: id } = req.params; + + await roomService.delete(id, userId); + res.sendStatus(204); + }; + + changeName = async ( + req: Request, + res: Response>, + ) => { + const { name } = req.body; + const { id: userId } = req.user!; + const { roomId: id } = req.params; + + const roomWithRole = await roomService.changeName(id, userId, name); + + res.json({ + message: 'OK', + data: roomWithRole, + }); + }; +} + +export const roomController = new RoomController(); diff --git a/src/createApp.ts b/src/createApp.ts new file mode 100644 index 000000000..cc0f74012 --- /dev/null +++ b/src/createApp.ts @@ -0,0 +1,30 @@ +import cors from 'cors'; +import express, { Express } from 'express'; + +import { authRoute } from './routes/auth.route'; +import { roomRoute } from './routes/room.route'; + +import { errorMiddleware } from './middlewares/error.middleware'; + +const CLIENT_URL = process.env.CLIENT_URL; + +export function createApp(): Express { + const app = express(); + + app.use( + cors({ + methods: ['POST', 'PATCH', 'DELETE'], + credentials: true, + origin: CLIENT_URL, + }), + ); + + app.use(express.json()); + + app.use('/api/auth', authRoute); + app.use('/api/rooms', roomRoute); + + app.use(errorMiddleware); + + return app; +} diff --git a/src/emitters/message.emitter.ts b/src/emitters/message.emitter.ts new file mode 100644 index 000000000..106de8656 --- /dev/null +++ b/src/emitters/message.emitter.ts @@ -0,0 +1,12 @@ +import EventEmitter from 'events'; +import { MessagePreview } from '../types/MessagePreview'; + +interface MyEvents { + delete: [{ roomId: string }]; + leave: [{ roomId: string; userId: string }]; + changeName: [{ roomId: string; newName: string }]; + + message: [{ roomId: string; preview: MessagePreview }]; +} + +export const messageEmitter = new EventEmitter(); diff --git a/src/entity/member.repository.ts b/src/entity/member.repository.ts new file mode 100644 index 000000000..23d52b390 --- /dev/null +++ b/src/entity/member.repository.ts @@ -0,0 +1,32 @@ +import { db } from '../utils/db'; +import { Member } from '@prisma/client'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +class MemberRepository { + create( + roomId: string, + userId: string, + tx?: PrismaTransactionClient, + ): Promise { + return (tx || db).member.create({ data: { roomId, userId } }); + } + + delete(roomId: string, userId: string): Promise { + return db.member.delete({ + where: { + roomId_userId: { + roomId, + userId, + }, + }, + }); + } + + get(roomId: string, userId: string): Promise { + return db.member.findUnique({ + where: { roomId_userId: { roomId, userId } }, + }); + } +} + +export const memberRepository = new MemberRepository(); diff --git a/src/entity/message.repository.ts b/src/entity/message.repository.ts new file mode 100644 index 000000000..4415b0994 --- /dev/null +++ b/src/entity/message.repository.ts @@ -0,0 +1,29 @@ +import { db } from '../utils/db'; +import { RawMessage } from '../types/RawMessage'; + +class MessageRepository { + getAll(roomId: string): Promise { + return db.message.findMany({ + where: { roomId }, + orderBy: { createdAt: 'desc' }, + + include: { + author: true, + }, + + take: 100, + }); + } + + create(roomId: string, authorId: string, text: string): Promise { + return db.message.create({ + data: { roomId, authorId, text }, + + include: { + author: true, + }, + }); + } +} + +export const messageRepository = new MessageRepository(); diff --git a/src/entity/room.repository.ts b/src/entity/room.repository.ts new file mode 100644 index 000000000..24333ce4c --- /dev/null +++ b/src/entity/room.repository.ts @@ -0,0 +1,74 @@ +import { db } from '../utils/db'; +import { Member, Room } from '@prisma/client'; +import { RawMessage } from '../types/RawMessage'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +type RawRoomInfo = Room & { messages: RawMessage[] }; +type RawRoomWithRole = Room & { members: Pick[] }; + +class RoomRepository { + getSummary(userId: string): Promise { + return db.room.findMany({ + where: { + members: { + some: { userId }, + }, + }, + + include: { + messages: { + take: 1, + orderBy: { createdAt: 'desc' }, + + include: { + author: true, + }, + }, + }, + }); + } + + get(id: string): Promise { + return db.room.findUnique({ where: { id } }); + } + + async getWithMember( + id: string, + userId: string, + ): Promise { + return db.room.findUnique({ + where: { id }, + include: { + members: { + where: { userId }, + select: { id: true }, + }, + }, + }); + } + + create( + name: string, + creatorId: string, + tx?: PrismaTransactionClient, + ): Promise { + return (tx || db).room.create({ + data: { name, creatorId }, + }); + } + + delete(id: string) { + return db.room.delete({ + where: { id }, + }); + } + + changeName(id: string, name: string): Promise { + return db.room.update({ + where: { id }, + data: { name, updatedAt: new Date() }, + }); + } +} + +export const roomRepository = new RoomRepository(); diff --git a/src/entity/token.repository.ts b/src/entity/token.repository.ts new file mode 100644 index 000000000..24221ea4e --- /dev/null +++ b/src/entity/token.repository.ts @@ -0,0 +1,28 @@ +import { db } from '../utils/db'; +import { Token } from '@prisma/client'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +class TokenRepository { + async create( + userId: string, + token: string, + tx?: PrismaTransactionClient, + ): Promise { + return (tx || db).token.create({ + data: { + userId, + token, + }, + }); + } + + async delete(token: string, tx?: PrismaTransactionClient): Promise { + return (tx || db).token.delete({ + where: { + token, + }, + }); + } +} + +export const tokenRepository = new TokenRepository(); diff --git a/src/entity/user.repository.ts b/src/entity/user.repository.ts new file mode 100644 index 000000000..1ed720599 --- /dev/null +++ b/src/entity/user.repository.ts @@ -0,0 +1,15 @@ +import { db } from '../utils/db'; +import { User } from '@prisma/client'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +class UserRepository { + create(name: string, tx?: PrismaTransactionClient): Promise { + return (tx || db).user.create({ + data: { + name, + }, + }); + } +} + +export const userRepository = new UserRepository(); diff --git a/src/exceptions/api.error.ts b/src/exceptions/api.error.ts new file mode 100644 index 000000000..c969dc845 --- /dev/null +++ b/src/exceptions/api.error.ts @@ -0,0 +1,61 @@ +type Errors = Record | undefined; + +interface ApiErrorParams { + message: string; + status: number; + errors?: Errors; +} + +export class ApiError extends Error { + status: number; + errors: Errors; + + constructor({ message, status, errors }: ApiErrorParams) { + super(message); + + this.status = status; + this.errors = errors; + + Object.setPrototypeOf(this, ApiError.prototype); + } + + static badRequest(message: string, errors?: Errors) { + return new ApiError({ + message, + status: 400, + ...(errors && { errors }), + }); + } + + static unauthorized(message: string, errors?: Errors) { + return new ApiError({ + message, + status: 401, + ...(errors && { errors }), + }); + } + + static forbidden(message: string = 'Access denied', errors?: Errors) { + return new ApiError({ + message: message, + status: 403, + ...(errors && { errors }), + }); + } + + static notFound(message: string = 'Not Found', errors?: Errors) { + return new ApiError({ + message: message, + status: 404, + ...(errors && { errors }), + }); + } + + static conflict(message: string, errors?: Errors) { + return new ApiError({ + message, + status: 409, + ...(errors && { errors }), + }); + } +} diff --git a/src/exceptions/wss.error.ts b/src/exceptions/wss.error.ts new file mode 100644 index 000000000..c6af3e925 --- /dev/null +++ b/src/exceptions/wss.error.ts @@ -0,0 +1,35 @@ +interface WssErrorParams { + message: string; + code: number; +} + +export class WssError extends Error { + code: number; + + constructor({ message, code }: WssErrorParams) { + super(message); + this.code = code; + + Object.setPrototypeOf(this, WssError.prototype); + } + + static unauthorized(message = 'Unauthorized') { + return new WssError({ message, code: 1008 }); + } + + static unsupported(message = 'Unsupported data') { + return new WssError({ message, code: 1003 }); + } + + static notFound(message = 'Not found') { + return new WssError({ message, code: 1009 }); + } + + static forbidden(message = 'Access denied') { + return new WssError({ message, code: 1008 }); + } + + static conflict(message = 'Conflict') { + return new WssError({ message, code: 1010 }); + } +} diff --git a/src/index.js b/src/index.js deleted file mode 100644 index ad9a93a7c..000000000 --- a/src/index.js +++ /dev/null @@ -1 +0,0 @@ -'use strict'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 000000000..1e1adcfb2 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,55 @@ +import { db } from './utils/db'; + +import { createApp } from './createApp'; +import { createWss } from './wss/createWss'; + +import { messageEmitter } from './emitters/message.emitter'; +import closeWithGrace, { CloseWithGraceCallback } from 'close-with-grace'; + +const app = createApp(); +const PORT = process.env.PORT || 3000; + +const server = app.listen(PORT, () => console.log('Server is running.')); +const wss = createWss(server); + +const cb: CloseWithGraceCallback = async ({ err, signal }) => { + if (err) { + console.error('Closing server with error', err); + } else { + console.log(`${signal} received, closing server`); + } + + try { + await new Promise((resolve, reject) => { + server.close((closeErr) => { + if (closeErr) { + reject(closeErr); + } else { + resolve(); + } + }); + }); + console.log('HTTP server closed'); + + await db.$disconnect(); + console.log('Database closed'); + messageEmitter.removeAllListeners(); + + await new Promise((resolve, reject) => { + wss.close((wsErr) => { + if (wsErr) { + reject(wsErr); + } else { + resolve(); + } + }); + }); + + console.log('WebSocket server closed'); + console.log('Server is closed'); + } catch (error) { + console.error('Error during shutdown process:', error); + } +}; + +closeWithGrace({ delay: 10000 }, cb); diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts new file mode 100644 index 000000000..fbe8d135c --- /dev/null +++ b/src/middlewares/auth.middleware.ts @@ -0,0 +1,24 @@ +import { NextFunction, Request, Response } from 'express'; + +import { jwt } from '../utils/jwt'; +import { ApiError } from '../exceptions/api.error'; +import { AuthorizationTokenSchema } from '../schemas/token.schema'; + +export async function authMiddleware( + req: Request, + _res: Response, + next: NextFunction, +) { + const verifiedHeaders = req.headers as AuthorizationTokenSchema; + const [, token] = verifiedHeaders['authorization'].split(' '); + + const normalizedUser = jwt.validateAccessToken(token); + + if (!normalizedUser) { + throw ApiError.unauthorized('Invalid access token'); + } + + req.user = normalizedUser; + + next(); +} diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts new file mode 100644 index 000000000..e8562769d --- /dev/null +++ b/src/middlewares/error.middleware.ts @@ -0,0 +1,17 @@ +import { ErrorRequestHandler } from 'express'; +import { ApiError } from '../exceptions/api.error'; + +export const errorMiddleware: ErrorRequestHandler = (err, _req, res, _next) => { + if (err instanceof ApiError) { + res.status(err.status).json({ message: err.message, errors: err.errors }); + return; + } + + if (err instanceof SyntaxError) { + res.status(400).json({ message: 'Invalid JSON syntax' }); + return; + } + + console.log(err); + res.status(500).json({ message: 'Internal Server Error' }); +}; diff --git a/src/middlewares/validation.middleware.ts b/src/middlewares/validation.middleware.ts new file mode 100644 index 000000000..4a19016b6 --- /dev/null +++ b/src/middlewares/validation.middleware.ts @@ -0,0 +1,34 @@ +import { ZodError, ZodTypeAny } from 'zod'; +import { Request, Response, NextFunction } from 'express'; + +import { ApiError } from '../exceptions/api.error'; + +export function validationMiddleware( + schema: ZodTypeAny, + source: 'body' | 'params' | 'cookies' | 'headers' = 'body', +) { + return (req: Request, _res: Response, next: NextFunction) => { + try { + const data = req[source]; + + schema.parse(data); + next(); + } catch (error) { + if (error instanceof ZodError) { + const formattedErrors = error.errors.reduce( + (acc, { path, message }) => { + const field = path.join('.'); + acc[field] = message; + + return acc; + }, + {} as Record, + ); + + throw ApiError.badRequest('Validation error', formattedErrors); + } + + next(error); + } + }; +} diff --git a/src/routes/auth.route.ts b/src/routes/auth.route.ts new file mode 100644 index 000000000..9ff4bf958 --- /dev/null +++ b/src/routes/auth.route.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; +import cookieParser from 'cookie-parser'; +import { authController } from '../controllers/auth.controller'; + +import { nameSchema } from '../schemas/name.schema'; +import { tokenSchema } from '../schemas/token.schema'; +import { validationMiddleware } from '../middlewares/validation.middleware'; + +export const authRoute = Router(); + +authRoute.post( + '/registration', + validationMiddleware(nameSchema), + authController.register, +); + +authRoute.post( + '/refresh-token', + cookieParser(), + validationMiddleware(tokenSchema.refresh, 'cookies'), + authController.refreshToken, +); diff --git a/src/routes/member.route.ts b/src/routes/member.route.ts new file mode 100644 index 000000000..ffa9aece9 --- /dev/null +++ b/src/routes/member.route.ts @@ -0,0 +1,7 @@ +import { Router } from 'express'; +import { memberController } from '../controllers/member.controller'; + +export const memberRoute = Router({ mergeParams: true }); + +memberRoute.post('/', memberController.join); +memberRoute.delete('/me', memberController.leave); diff --git a/src/routes/message.route.ts b/src/routes/message.route.ts new file mode 100644 index 000000000..28f554b3f --- /dev/null +++ b/src/routes/message.route.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import { messageController } from '../controllers/message.controller'; + +import { textSchema } from '../schemas/text.schema'; +import { validationMiddleware } from '../middlewares/validation.middleware'; + +export const messageRoute = Router({ mergeParams: true }); + +messageRoute.get('/', messageController.getAll); + +messageRoute.post( + '/', + validationMiddleware(textSchema), + messageController.create, +); diff --git a/src/routes/room.detail.route.ts b/src/routes/room.detail.route.ts new file mode 100644 index 000000000..2434ab2e2 --- /dev/null +++ b/src/routes/room.detail.route.ts @@ -0,0 +1,25 @@ +import { Router } from 'express'; + +import { nameSchema } from '../schemas/name.schema'; +import { roomIdSchema } from '../schemas/roomId.schema'; +import { validationMiddleware } from '../middlewares/validation.middleware'; + +import { memberRoute } from './member.route'; +import { messageRoute } from './message.route'; +import { roomController } from '../controllers/room.controller'; + +export const roomDetailRoute = Router({ mergeParams: true }); + +roomDetailRoute.use(validationMiddleware(roomIdSchema, 'params')); + +roomDetailRoute.get('/', roomController.getWithRole); +roomDetailRoute.delete('/', roomController.delete); + +roomDetailRoute.patch( + '/', + validationMiddleware(nameSchema), + roomController.changeName, +); + +roomDetailRoute.use('/members', memberRoute); +roomDetailRoute.use('/messages', messageRoute); diff --git a/src/routes/room.route.ts b/src/routes/room.route.ts new file mode 100644 index 000000000..32e6521cc --- /dev/null +++ b/src/routes/room.route.ts @@ -0,0 +1,22 @@ +import { Router } from 'express'; + +import { nameSchema } from '../schemas/name.schema'; +import { tokenSchema } from '../schemas/token.schema'; + +import { authMiddleware } from '../middlewares/auth.middleware'; +import { validationMiddleware } from '../middlewares/validation.middleware'; + +import { roomDetailRoute } from './room.detail.route'; +import { roomController } from '../controllers/room.controller'; + +export const roomRoute = Router(); + +roomRoute.use( + validationMiddleware(tokenSchema.authorization, 'headers'), + authMiddleware, +); + +roomRoute.get('/', roomController.getSummary); +roomRoute.post('/', validationMiddleware(nameSchema), roomController.create); + +roomRoute.use('/:roomId', roomDetailRoute); diff --git a/src/schemas/name.schema.ts b/src/schemas/name.schema.ts new file mode 100644 index 000000000..0273e77d4 --- /dev/null +++ b/src/schemas/name.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const nameSchema = z.object({ + name: z + .string({ + required_error: 'Name is required', + invalid_type_error: 'Name must be a string', + }) + .trim() + .min(6, 'Name must be at least 6 characters long') + .max(50, 'Name must be at most 50 characters long'), +}); + +export type NameSchema = z.infer; diff --git a/src/schemas/roomId.schema.ts b/src/schemas/roomId.schema.ts new file mode 100644 index 000000000..a28daca6c --- /dev/null +++ b/src/schemas/roomId.schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const roomIdSchema = z.object({ + roomId: z.string().uuid('Invalid roomId'), +}); + +export type RoomIdSchema = z.infer; diff --git a/src/schemas/text.schema.ts b/src/schemas/text.schema.ts new file mode 100644 index 000000000..71425ec7e --- /dev/null +++ b/src/schemas/text.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const textSchema = z.object({ + text: z + .string({ + required_error: 'Message is required', + invalid_type_error: 'Message must be a string', + }) + .trim() + .min(6, 'Message must be at least 6 characters long') + .max(100, 'Message must be at most 50 characters long'), +}); + +export type TextSchema = z.infer; diff --git a/src/schemas/token.schema.ts b/src/schemas/token.schema.ts new file mode 100644 index 000000000..bce162786 --- /dev/null +++ b/src/schemas/token.schema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +const refresh = z.object({ + refreshToken: z + .string({ + required_error: 'RefreshToken is required', + invalid_type_error: 'RefreshToken must be a string', + }) + .jwt({ message: 'Invalid refresh token' }), +}); + +const authorization = z.object({ + authorization: z + .string({ + required_error: 'AccessToken is required', + invalid_type_error: 'AccessToken must be a string', + }) + .regex(/^Bearer\s[\w-]+\.[\w-]+\.[\w-]+$/, 'Invalid access token'), +}); + +const access = z.object({ + accessToken: z + .string({ + required_error: 'AccessToken is required', + invalid_type_error: 'AccessToken must be a string', + }) + .jwt({ message: 'Invalid access token' }), +}); + +export const tokenSchema = { + access, + refresh, + authorization, +}; + +export type AccessTokenSchema = z.infer; +export type RefreshTokenSchema = z.infer; +export type AuthorizationTokenSchema = z.infer; diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 000000000..aa11f5979 --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,21 @@ +import { db } from '../utils/db'; +import { userService } from './user.service'; +import { tokenService } from './token.service'; + +import { AuthData } from '../types/AuthData'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +class AuthService { + async register(name: string): Promise { + return db.$transaction( + async (tx: PrismaTransactionClient): Promise => { + const normalizedUser = await userService.create(name, tx); + const tokens = await tokenService.create(normalizedUser, tx); + + return { normalizedUser, ...tokens }; + }, + ); + } +} + +export const authService = new AuthService(); diff --git a/src/services/member.service.ts b/src/services/member.service.ts new file mode 100644 index 000000000..f9fcb2e76 --- /dev/null +++ b/src/services/member.service.ts @@ -0,0 +1,63 @@ +import { Member } from '@prisma/client'; +import { roomService } from './room.service'; +import { memberRepository } from '../entity/member.repository'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +import { ApiError } from '../exceptions/api.error'; +import { NormalizedRoom } from '../types/NormalizedRoom'; +import { messageEmitter } from '../emitters/message.emitter'; + +class MemberService { + async create( + roomId: string, + userId: string, + tx?: PrismaTransactionClient, + ): Promise { + const member = await memberRepository.create(roomId, userId, tx); + + return member; + } + + async join(roomId: string, userId: string): Promise { + const normalizedRoom = await roomService.get(roomId); + + if (!normalizedRoom) { + throw ApiError.notFound('Joining error', { + name: 'Room with this name does not exist', + }); + } + + try { + await memberRepository.create(roomId, userId); + return normalizedRoom; + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as any).code === 'P2002' + ) { + throw ApiError.conflict('Joining error', { + name: 'Room already in your list', + }); + } + + throw err; + } + } + + async leave(roomId: string, userId: string): Promise { + const roomWithRole = await roomService.getWithRole(roomId, userId); + + if (roomWithRole.creator) { + throw ApiError.badRequest( + 'You can not leave this room because you are a creator', + ); + } + + await memberRepository.delete(roomId, userId); + messageEmitter.emit('leave', { roomId, userId }); + } +} + +export const memberService = new MemberService(); diff --git a/src/services/message.service.ts b/src/services/message.service.ts new file mode 100644 index 000000000..c38ea7ce4 --- /dev/null +++ b/src/services/message.service.ts @@ -0,0 +1,47 @@ +import { Message } from '@prisma/client'; + +import { roomService } from './room.service'; +import { userService } from './user.service'; +import { RawMessage } from '../types/RawMessage'; +import { MessagePreview } from '../types/MessagePreview'; +import { NormalizedMessage } from '../types/NormalizedMessage'; +import { messageRepository } from '../entity/message.repository'; + +import { messageEmitter } from '../emitters/message.emitter'; + +class MessageService { + normalize({ id, text, createdAt }: Message): NormalizedMessage { + return { id, text, time: createdAt }; + } + + buildMessagePreview({ author, ...message }: RawMessage): MessagePreview { + return { + ...this.normalize(message), + author: userService.normalize(author), + }; + } + + async getAll(roomId: string, userId: string): Promise { + await roomService.getWithRole(roomId, userId); + const messages = await messageRepository.getAll(roomId); + + return messages.map((rawMessage) => this.buildMessagePreview(rawMessage)); + } + + async create( + roomId: string, + authorId: string, + text: string, + ): Promise { + await roomService.getWithRole(roomId, authorId); + + const rawMessage = await messageRepository.create(roomId, authorId, text); + const preview = this.buildMessagePreview(rawMessage); + + messageEmitter.emit('message', { roomId, preview }); + + return preview; + } +} + +export const messageService = new MessageService(); diff --git a/src/services/room.service.ts b/src/services/room.service.ts new file mode 100644 index 000000000..c2c7aa4e3 --- /dev/null +++ b/src/services/room.service.ts @@ -0,0 +1,145 @@ +import { Room } from '@prisma/client'; +import { RawMessage } from '../types/RawMessage'; +import { RoomPreview } from '../types/RoomPreview'; +import { RoomWithRole } from '../types/RoomWithRole'; +import { NormalizedRoom } from '../types/NormalizedRoom'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +import { messageService } from './message.service'; +import { roomRepository } from '../entity/room.repository'; + +import { ApiError } from '../exceptions/api.error'; +import { messageEmitter } from '../emitters/message.emitter'; + +class RoomService { + normalize({ id, name }: Room): NormalizedRoom { + return { id, name }; + } + + buildRoomPreview( + room: Room, + creator: boolean = false, + lastMessage: RawMessage | null = null, + ): RoomPreview { + return { + ...this.normalize(room), + creator, + + lastMessage: lastMessage + ? messageService.buildMessagePreview(lastMessage) + : null, + }; + } + + async getSummary(userId: string): Promise { + const rawRooms = await roomRepository.getSummary(userId); + + return rawRooms.map(({ messages, ...room }) => + this.buildRoomPreview(room, userId === room.creatorId, messages[0]), + ); + } + + async get(id: string): Promise { + const room = await roomRepository.get(id); + + return room ? this.normalize(room) : null; + } + + async create( + name: string, + userId: string, + tx?: PrismaTransactionClient, + ): Promise { + try { + const room = await roomRepository.create(name, userId, tx); + + return this.buildRoomPreview(room, true); + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as any).code === 'P2002' + ) { + throw ApiError.conflict('Creating error', { + name: 'Room already exist', + }); + } + + throw err; + } + } + + async getWithRole(id: string, userId: string): Promise { + const roomWithMember = await roomRepository.getWithMember(id, userId); + + if (!roomWithMember) { + throw ApiError.notFound('Room not found'); + } + + const { members, ...room } = roomWithMember; + + if (!members.length) { + throw ApiError.forbidden(); + } + + return { + ...this.normalize(room), + creator: userId === room.creatorId, + }; + } + + async delete(id: string, userId: string): Promise { + const roomWithRole = await this.getWithRole(id, userId); + + if (!roomWithRole.creator) { + throw ApiError.forbidden(); + } + + await roomRepository.delete(id); + messageEmitter.emit('delete', { roomId: id }); + } + + async changeName( + id: string, + userId: string, + newName: string, + ): Promise { + const roomWithRole = await this.getWithRole(id, userId); + + if (!roomWithRole.creator) { + throw ApiError.forbidden(); + } + + if (roomWithRole.name === newName) { + throw ApiError.badRequest('Changing error', { + name: 'New name must be different', + }); + } + + try { + const rawRoom = await roomRepository.changeName(id, newName); + messageEmitter.emit('changeName', { roomId: id, newName }); + + return { + ...this.normalize(rawRoom), + creator: true, + }; + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as any).code === 'P2002' + ) { + throw ApiError.conflict('Changing error', { + name: 'Room already exist', + }); + } + + throw err; + } + } +} + +export const roomService = new RoomService(); diff --git a/src/services/token.service.ts b/src/services/token.service.ts new file mode 100644 index 000000000..06ca4740a --- /dev/null +++ b/src/services/token.service.ts @@ -0,0 +1,65 @@ +import { createHash } from 'crypto'; + +import { db } from '../utils/db'; +import { jwt } from '../utils/jwt'; +import { ApiError } from '../exceptions/api.error'; +import { tokenRepository } from '../entity/token.repository'; + +import { AuthData } from '../types/AuthData'; +import { NormalizedUser } from '../types/NormalizedUser'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +class TokenService { + private hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); + } + + async create( + normalizedUser: NormalizedUser, + tx?: PrismaTransactionClient, + ): Promise<{ accessToken: string; refreshToken: string }> { + const accessToken = jwt.generateAccessToken(normalizedUser); + const refreshToken = jwt.generateRefreshToken(normalizedUser); + + await tokenRepository.create( + normalizedUser.id, + this.hashToken(refreshToken), + tx, + ); + + return { accessToken, refreshToken }; + } + + async refresh(token: string): Promise { + const normalizedUser = jwt.validateRefreshToken(token); + + if (!normalizedUser) { + throw ApiError.unauthorized('Invalid refresh token'); + } + + const result = await db.$transaction( + async ( + tx: PrismaTransactionClient, + ): Promise> => { + try { + await tokenRepository.delete(this.hashToken(token), tx); + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as any).code === 'P2025' + ) { + throw ApiError.unauthorized('Invalid refresh token'); + } + } + + return await this.create(normalizedUser, tx); + }, + ); + + return { normalizedUser, ...result }; + } +} + +export const tokenService = new TokenService(); diff --git a/src/services/user.service.ts b/src/services/user.service.ts new file mode 100644 index 000000000..ca5f232fd --- /dev/null +++ b/src/services/user.service.ts @@ -0,0 +1,36 @@ +import { User } from '@prisma/client'; +import { NormalizedUser } from '../types/NormalizedUser'; +import { PrismaTransactionClient } from '../types/PrismaTransactionClient'; + +import { ApiError } from '../exceptions/api.error'; +import { userRepository } from '../entity/user.repository'; + +class UserService { + normalize({ id, name }: User): NormalizedUser { + return { id, name }; + } + + async create( + name: string, + tx?: PrismaTransactionClient, + ): Promise { + try { + return this.normalize(await userRepository.create(name, tx)); + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + 'code' in err && + (err as any).code === 'P2002' + ) { + throw ApiError.conflict('Registration error', { + name: 'User already exists', + }); + } + + throw err; + } + } +} + +export const userService = new UserService(); diff --git a/src/types/AuthData.ts b/src/types/AuthData.ts new file mode 100644 index 000000000..b186fd545 --- /dev/null +++ b/src/types/AuthData.ts @@ -0,0 +1,7 @@ +import { NormalizedUser } from './NormalizedUser'; + +export interface AuthData { + accessToken: string; + refreshToken: string; + normalizedUser: NormalizedUser; +} diff --git a/src/types/MessagePreview.ts b/src/types/MessagePreview.ts new file mode 100644 index 000000000..8b4c81369 --- /dev/null +++ b/src/types/MessagePreview.ts @@ -0,0 +1,4 @@ +import { NormalizedUser } from './NormalizedUser'; +import { NormalizedMessage } from './NormalizedMessage'; + +export type MessagePreview = NormalizedMessage & { author: NormalizedUser }; diff --git a/src/types/NormalizedMessage.ts b/src/types/NormalizedMessage.ts new file mode 100644 index 000000000..b657a5466 --- /dev/null +++ b/src/types/NormalizedMessage.ts @@ -0,0 +1,3 @@ +import { Message } from '@prisma/client'; + +export type NormalizedMessage = Pick & { time: Date }; diff --git a/src/types/NormalizedRoom.ts b/src/types/NormalizedRoom.ts new file mode 100644 index 000000000..64b951126 --- /dev/null +++ b/src/types/NormalizedRoom.ts @@ -0,0 +1,3 @@ +import { Room } from '@prisma/client'; + +export type NormalizedRoom = Pick; diff --git a/src/types/NormalizedUser.ts b/src/types/NormalizedUser.ts new file mode 100644 index 000000000..4e39f37e6 --- /dev/null +++ b/src/types/NormalizedUser.ts @@ -0,0 +1,3 @@ +import { User } from '@prisma/client'; + +export type NormalizedUser = Pick; diff --git a/src/types/PrismaTransactionClient.ts b/src/types/PrismaTransactionClient.ts new file mode 100644 index 000000000..18b7d3cd4 --- /dev/null +++ b/src/types/PrismaTransactionClient.ts @@ -0,0 +1,6 @@ +import { PrismaClient } from '@prisma/client'; + +export type PrismaTransactionClient = Omit< + PrismaClient, + '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' +>; diff --git a/src/types/RawMessage.ts b/src/types/RawMessage.ts new file mode 100644 index 000000000..c1dce5e38 --- /dev/null +++ b/src/types/RawMessage.ts @@ -0,0 +1,3 @@ +import { User, Message } from '@prisma/client'; + +export type RawMessage = Message & { author: User }; diff --git a/src/types/ResponseBody.ts b/src/types/ResponseBody.ts new file mode 100644 index 000000000..e40aa4962 --- /dev/null +++ b/src/types/ResponseBody.ts @@ -0,0 +1,4 @@ +export interface ResponseBody { + message: string; + data: T; +} diff --git a/src/types/RoomPreview.ts b/src/types/RoomPreview.ts new file mode 100644 index 000000000..d0c000a81 --- /dev/null +++ b/src/types/RoomPreview.ts @@ -0,0 +1,7 @@ +import { NormalizedUser } from './NormalizedUser'; +import { NormalizedMessage } from './NormalizedMessage'; + +export type RoomPreview = NormalizedUser & { + creator: boolean; + lastMessage: NormalizedMessage | null; +}; diff --git a/src/types/RoomWithRole.ts b/src/types/RoomWithRole.ts new file mode 100644 index 000000000..41ef842e7 --- /dev/null +++ b/src/types/RoomWithRole.ts @@ -0,0 +1,3 @@ +import { RoomPreview } from './RoomPreview'; + +export type RoomWithRole = Omit; diff --git a/src/types/express/index.d.ts b/src/types/express/index.d.ts new file mode 100644 index 000000000..486bee3e9 --- /dev/null +++ b/src/types/express/index.d.ts @@ -0,0 +1,9 @@ +import { NormalizedUser } from '../NormalizedUser'; + +declare global { + namespace Express { + interface Request { + user?: NormalizedUser; + } + } +} diff --git a/src/utils/db.ts b/src/utils/db.ts new file mode 100644 index 000000000..4a09be7f3 --- /dev/null +++ b/src/utils/db.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client'; + +export const db = new PrismaClient(); diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 000000000..14d0d25fb --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,42 @@ +import jsonwebtoken, { JwtPayload } from 'jsonwebtoken'; +import { NormalizedUser } from '../types/NormalizedUser'; + +const JWT_ACCESS_KEY = process.env.JWT_ACCESS_KEY as string; +const JWT_REFRESH_KEY = process.env.JWT_REFRESH_KEY as string; + +function generateAccessToken(data: NormalizedUser): string { + return jsonwebtoken.sign(data, JWT_ACCESS_KEY, { + expiresIn: '10m', + }); +} + +function validateAccessToken(token: string): NormalizedUser | undefined { + try { + const decoded = jsonwebtoken.verify(token, JWT_ACCESS_KEY) as JwtPayload; + const { exp, iat, ...payload } = decoded; + + return payload as NormalizedUser; + } catch (_) {} +} + +function generateRefreshToken(data: NormalizedUser): string { + return jsonwebtoken.sign(data, JWT_REFRESH_KEY, { + expiresIn: '7d', + }); +} + +function validateRefreshToken(token: string): NormalizedUser | undefined { + try { + const decoded = jsonwebtoken.verify(token, JWT_REFRESH_KEY) as JwtPayload; + const { exp, iat, ...payload } = decoded; + + return payload as NormalizedUser; + } catch (_) {} +} + +export const jwt = { + generateAccessToken, + validateAccessToken, + generateRefreshToken, + validateRefreshToken, +}; diff --git a/src/wss/addListeners.ts b/src/wss/addListeners.ts new file mode 100644 index 000000000..265400b97 --- /dev/null +++ b/src/wss/addListeners.ts @@ -0,0 +1,27 @@ +import { messageEmitter } from '../emitters/message.emitter'; +import { roomManager } from './room.manager'; + +export function addListeners() { + messageEmitter.on('message', ({ roomId, preview }) => { + roomManager.broadcast(roomId, { + type: 'message', + payload: preview, + }); + }); + + messageEmitter.on('leave', ({ roomId, userId }) => { + const ws = roomManager.getSocket(roomId, userId); + ws?.close(4001, 'You have left the room'); + }); + + messageEmitter.on('delete', ({ roomId }) => { + roomManager.delete(roomId); + }); + + messageEmitter.on('changeName', ({ roomId, newName }) => { + roomManager.broadcast(roomId, { + type: 'name_changed', + payload: newName, + }); + }); +} diff --git a/src/wss/createWss.ts b/src/wss/createWss.ts new file mode 100644 index 000000000..9546ede64 --- /dev/null +++ b/src/wss/createWss.ts @@ -0,0 +1,16 @@ +import { Server } from 'http'; +import { WebSocketServer, Server as WBServer } from 'ws'; + +import { addListeners } from './addListeners'; +import { errorHandler } from './handlers/error.handler'; +import { connectionHandler } from './handlers/connection.handler'; + +export function createWss(server: Server): WBServer { + const wws = new WebSocketServer({ server }); + + wws.on('connection', errorHandler(connectionHandler)); + + addListeners(); + + return wws; +} diff --git a/src/wss/handlers/auth.handler.ts b/src/wss/handlers/auth.handler.ts new file mode 100644 index 000000000..8fdfff2af --- /dev/null +++ b/src/wss/handlers/auth.handler.ts @@ -0,0 +1,51 @@ +import { WebSocket } from 'ws'; +import { IncomingMessage } from 'http'; + +import { tokenSchema } from '../../schemas/token.schema'; +import { roomIdSchema } from '../../schemas/roomId.schema'; + +import { jwt } from '../../utils/jwt'; +import { WssError } from '../../exceptions/wss.error'; +import { ApiError } from '../../exceptions/api.error'; + +import { roomService } from '../../services/room.service'; + +const authSchema = tokenSchema.access.merge(roomIdSchema); + +export async function authHandler( + _ws: WebSocket, + req: IncomingMessage, +): Promise<{ roomId: string; userId: string }> { + const url = new URL(req.url!, `http://${req.headers.host}`); + const searchParams = Object.fromEntries(url.searchParams.entries()); + + const result = authSchema.safeParse(searchParams); + + if (!result.success) { + throw WssError.unsupported('Invalid auth params'); + } + + const { accessToken, roomId } = result.data; + const userData = jwt.validateAccessToken(accessToken); + + if (!userData) { + throw WssError.unauthorized('Invalid token'); + } + + try { + await roomService.getWithRole(roomId, userData.id); + return { roomId, userId: userData.id }; + } catch (err) { + if (err instanceof ApiError) { + switch (err.status) { + case 404: + throw WssError.notFound('Room not found'); + case 403: + throw WssError.forbidden('Access denied to the room'); + case 409: + } + } + + throw err; + } +} diff --git a/src/wss/handlers/connection.handler.ts b/src/wss/handlers/connection.handler.ts new file mode 100644 index 000000000..4ac071c80 --- /dev/null +++ b/src/wss/handlers/connection.handler.ts @@ -0,0 +1,15 @@ +import { WebSocket } from 'ws'; +import { IncomingMessage } from 'http'; + +import { authHandler } from './auth.handler'; +import { roomManager } from '../room.manager'; + +export async function connectionHandler(ws: WebSocket, req: IncomingMessage) { + const { roomId, userId } = await authHandler(ws, req); + + roomManager.join(roomId, userId, ws); + + ws.on('close', () => { + roomManager.leave(roomId, ws); + }); +} diff --git a/src/wss/handlers/error.handler.ts b/src/wss/handlers/error.handler.ts new file mode 100644 index 000000000..eeda58db8 --- /dev/null +++ b/src/wss/handlers/error.handler.ts @@ -0,0 +1,23 @@ +import { WebSocket } from 'ws'; +import { IncomingMessage } from 'http'; +import { WssError } from '../../exceptions/wss.error'; + +export function errorHandler( + handler: (ws: WebSocket, req: IncomingMessage) => Promise | void, +) { + return async (ws: WebSocket, req: IncomingMessage) => { + try { + await handler(ws, req); + } catch (err) { + if (err instanceof WssError) { + ws.send(JSON.stringify({ error: err.message })); + ws.close(err.code, err.message); + + return; + } + + ws.send(JSON.stringify({ error: 'Internal server error' })); + ws.close(1011, 'Internal server error'); + } + }; +} diff --git a/src/wss/room.manager.ts b/src/wss/room.manager.ts new file mode 100644 index 000000000..eeaec4f84 --- /dev/null +++ b/src/wss/room.manager.ts @@ -0,0 +1,75 @@ +import { WebSocket } from 'ws'; +import { MessagePreview } from '../types/MessagePreview'; + +interface MessageBroadcast { + type: 'message'; + payload: MessagePreview; +} + +interface NameChangedBroadcast { + type: 'name_changed'; + payload: string; +} + +type RoomBroadcastData = MessageBroadcast | NameChangedBroadcast; + +class RoomManager { + private rooms = new Map>(); + + join(id: string, userId: string, ws: WebSocket) { + if (!this.rooms.has(id)) { + this.rooms.set(id, new Map()); + } + + this.rooms.get(id)!.set(userId, ws); + } + + leave(id: string, ws: WebSocket) { + const room = this.rooms.get(id); + + if (room) { + for (const [userId, socket] of room.entries()) { + if (socket === ws) { + room.delete(userId); + break; + } + } + + if (room.size === 0) { + this.rooms.delete(id); + } + } + } + + delete(id: string) { + const room = this.rooms.get(id); + + if (room) { + for (const [, client] of room) { + if (client.readyState === WebSocket.OPEN) { + client.close(4002, 'The room was deleted'); + } + } + + this.rooms.delete(id); + } + } + + broadcast(id: string, data: RoomBroadcastData) { + const room = this.rooms.get(id); + + if (room) { + for (const [, client] of room) { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(data)); + } + } + } + } + + getSocket(id: string, userId: string): WebSocket | undefined { + return this.rooms.get(id)?.get(userId); + } +} + +export const roomManager = new RoomManager(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..861f6b1c5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +}