diff --git a/src/controllers/message.controller.js b/src/controllers/message.controller.js new file mode 100644 index 000000000..29b8d5cbf --- /dev/null +++ b/src/controllers/message.controller.js @@ -0,0 +1,40 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { Message } from '../models/message.js'; +import { messageService } from '../services/message.service.js'; +import { userService } from '../services/user.service.js'; + +const getAllMessages = async (req, res, next) => { + try { + const { roomId } = req.params; + + const allMessages = await Message.findAll({ where: { roomId } }); + + res.send(allMessages); + } catch (error) { + next(error); + } +}; + +const createMessage = async (req, res, next) => { + try { + const { text } = req.body; + const { roomId } = req.params; + + if (!text) { + throw ApiError.badRequest('Enter the message'); + } + + const user = await userService.getUser(req); + + await messageService.createMessage(user, text, roomId); + + res.status(201).send(text); + } catch (error) { + next(error); + } +}; + +export const messageController = { + getAllMessages, + createMessage, +}; diff --git a/src/controllers/refresh.controller.js b/src/controllers/refresh.controller.js new file mode 100644 index 000000000..1eb26db6b --- /dev/null +++ b/src/controllers/refresh.controller.js @@ -0,0 +1,28 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { jwtService } from '../services/jwt.service.js'; +import { tokenService } from '../services/token.service.js'; +import { userService } from '../services/user.service.js'; +import { userController } from './user.controller.js'; + +const refresh = async (req, res, next) => { + try { + const { refreshToken } = req.cookies; + + const userData = await jwtService.verifyRefresh(refreshToken); + const token = await tokenService.getByToken(refreshToken); + + if (!userData || !token) { + throw ApiError.unauthorized(); + } + + const user = await userService.findByUserName(userData.username); + + userController.generateToken(res, user); + } catch (error) { + next(error); + } +}; + +export const refreshController = { + refresh, +}; diff --git a/src/controllers/room.controller.js b/src/controllers/room.controller.js new file mode 100644 index 000000000..d0afaaadb --- /dev/null +++ b/src/controllers/room.controller.js @@ -0,0 +1,101 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { Room } from '../models/room.js'; +import { roomService } from '../services/room.service.js'; +import { userService } from '../services/user.service.js'; + +const getAllRooms = async (req, res, next) => { + try { + const rooms = await Room.findAll(); + + res.send(rooms); + } catch (error) { + next(error); + } +}; + +const getRoomById = async (req, res, next) => { + try { + const { roomId } = req.params; + + const room = await roomService.getRoomById(roomId); + + if (!room) { + throw ApiError.notFound(); + } + + res.send(room); + } catch (error) { + next(error); + } +}; + +const createRoom = async (req, res, next) => { + try { + const { title, participants } = req.body; + + if (!title) { + throw ApiError.badRequest('Enter the title'); + } + + const user = await userService.getUser(req); + + await roomService.createRoom(user, title, participants); + + res.status(201).send({ message: 'Room created' }); + } catch (error) { + next(error); + } +}; + +const updateRoom = async (req, res, next) => { + try { + const { roomId } = req.params; + const { title, participants } = req.body; + + const room = await roomService.getRoomById(roomId); + + if (!room) { + throw ApiError.notFound(); + } + + if (!Array.isArray(participants)) { + throw ApiError.badRequest('Participants shoude be an array'); + } + + const user = await userService.getUser(req); + + await roomService.updateRoom(user, room, title, participants); + + const updatedRoom = await roomService.getRoomById(roomId); + + res.send(updatedRoom); + } catch (error) { + next(error); + } +}; + +const deleteRoom = async (req, res, next) => { + try { + const { roomId } = req.params; + + const room = await roomService.getRoomById(roomId); + + if (!room) { + throw ApiError.notFound(); + } + + await roomService.deleteRoom(roomId); + + res.status(204).send({ message: 'Room deleted' }); + } catch (error) { + next(error); + } +}; + +export const roomController = { + createRoom, + updateRoom, + deleteRoom, + getAllRooms, + getRoomById, +}; diff --git a/src/controllers/user.controller.js b/src/controllers/user.controller.js new file mode 100644 index 000000000..4c2007352 --- /dev/null +++ b/src/controllers/user.controller.js @@ -0,0 +1,43 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { jwtService } from '../services/jwt.service.js'; +import { tokenService } from '../services/token.service.js'; +import { userService } from '../services/user.service.js'; + +const createUser = async (req, res, next) => { + try { + const { username } = req.body; + + if (!username) { + throw ApiError.badRequest('Enter the username'); + } + + await userService.createUser(username); + + const user = await userService.findByUserName(username); + + await generateToken(res, user); + } catch (error) { + next(error); + } +}; + +const generateToken = async (res, user) => { + const normalizedUser = userService.normalize(user); + + const accessToken = jwtService.sign(normalizedUser); + const refreshAccessToken = jwtService.signRefresh(normalizedUser); + + await tokenService.save(normalizedUser.id, refreshAccessToken); + + res.cookie('refreshToken', refreshAccessToken, { + maxAge: 30 * 24 * 60 * 60 * 1000, + httpOnly: true, + }); + + res.send({ user: normalizedUser, accessToken }); +}; + +export const userController = { + createUser, + generateToken, +}; diff --git a/src/exeptions/api.error.js b/src/exeptions/api.error.js new file mode 100644 index 000000000..0a141242e --- /dev/null +++ b/src/exeptions/api.error.js @@ -0,0 +1,32 @@ +export class ApiError extends Error { + constructor({ message, status, errors = {} }) { + super(message); + + this.status = status; + this.errors = errors; + } + + static badRequest(message, errors) { + return new ApiError({ + message, + errors, + status: 400, + }); + } + + static unauthorized(errors) { + return new ApiError({ + message: 'unauthorized user', + errors, + status: 401, + }); + } + + static notFound(errors) { + return new ApiError({ + message: 'not found', + errors, + status: 404, + }); + } +} diff --git a/src/index.js b/src/index.js index ad9a93a7c..4839fe1c7 100644 --- a/src/index.js +++ b/src/index.js @@ -1 +1,41 @@ 'use strict'; + +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import { WebSocketServer } from 'ws'; +import { userRouter } from './routes/user.route.js'; +import { errorMiddleware } from './middlewares/errorMiddleware.js'; +import { roomsRouter } from './routes/rooms.route.js'; +import { messageRouter } from './routes/message.route.js'; +import { emmiter } from './utils/emmiter.js'; +import cookieParser from 'cookie-parser'; +import { refreshRouter } from './routes/refresh.route.js'; + +const PORT = process.env.PORT || 3005; + +const app = express(); + +app.use(cors()); +app.use(express.json()); +app.use(cookieParser()); + +app.use('/user', userRouter); +app.use('/rooms', roomsRouter); +app.use('/messages', messageRouter); +app.use('/refresh', refreshRouter); + +app.use(errorMiddleware); + +const server = app.listen(PORT, () => { + // eslint-disable-next-line no-console + console.log('server is running'); +}); + +const wss = new WebSocketServer({ server }); + +emmiter.on('message', (message) => { + for (const client of wss.clients) { + client.send(JSON.stringify(message)); + } +}); diff --git a/src/middlewares/authMiddleware.js b/src/middlewares/authMiddleware.js new file mode 100644 index 000000000..bdc6cb653 --- /dev/null +++ b/src/middlewares/authMiddleware.js @@ -0,0 +1,24 @@ +import { jwtService } from '../services/jwt.service.js'; + +export const authMiddleware = (req, res, next) => { + const authorization = req.headers['authorization'] || ''; + const [, token] = authorization.split(' '); + + if (!authorization || !token) { + res.sendStatus(401); + + return; + } + + const userData = jwtService.verify(token); + + if (!userData) { + res.sendStatus(401); + + return; + } + + req.user = userData; + + next(); +}; diff --git a/src/middlewares/errorMiddleware.js b/src/middlewares/errorMiddleware.js new file mode 100644 index 000000000..ddb9ef61f --- /dev/null +++ b/src/middlewares/errorMiddleware.js @@ -0,0 +1,15 @@ +import { ApiError } from '../exeptions/api.error.js'; + +export const errorMiddleware = (error, req, res, next) => { + if (error instanceof ApiError) { + res.status(error.status).send({ + message: error.message, + errors: error.errors, + }); + + return; + } + + res.statusCode = 500; + res.send(error.message); +}; diff --git a/src/models/message.js b/src/models/message.js new file mode 100644 index 000000000..f849c86af --- /dev/null +++ b/src/models/message.js @@ -0,0 +1,31 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; +import { User } from './user.js'; +import { Room } from './room.js'; + +export const Message = client.define( + 'message', + { + text: { + type: DataTypes.STRING, + allowNull: false, + }, + author: { + type: DataTypes.STRING, + allowNull: false, + }, + time: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + allowNull: false, + }, + }, + { + timestamps: false, + }, +); + +Message.belongsTo(User); +User.hasMany(Message); +Message.belongsTo(Room); +Room.hasMany(Message); diff --git a/src/models/room.js b/src/models/room.js new file mode 100644 index 000000000..1ddf8e800 --- /dev/null +++ b/src/models/room.js @@ -0,0 +1,18 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; +import { User } from './user.js'; + +export const Room = client.define('room', { + title: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + participants: { + type: DataTypes.ARRAY(DataTypes.STRING), + allowNull: false, + }, +}); + +Room.belongsTo(User); +User.hasMany(Room); diff --git a/src/models/token.js b/src/models/token.js new file mode 100644 index 000000000..5e0099182 --- /dev/null +++ b/src/models/token.js @@ -0,0 +1,13 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; +import { User } from './user.js'; + +export const Token = client.define('token', { + refreshToken: { + type: DataTypes.STRING, + allowNull: false, + }, +}); + +Token.belongsTo(User); +User.hasOne(Token); diff --git a/src/models/user.js b/src/models/user.js new file mode 100644 index 000000000..08e10d4a3 --- /dev/null +++ b/src/models/user.js @@ -0,0 +1,10 @@ +import { DataTypes } from 'sequelize'; +import { client } from '../utils/db.js'; + +export const User = client.define('user', { + username: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, +}); diff --git a/src/routes/message.route.js b/src/routes/message.route.js new file mode 100644 index 000000000..8e00d64c4 --- /dev/null +++ b/src/routes/message.route.js @@ -0,0 +1,18 @@ +import express from 'express'; +import { catchError } from '../utils/catchError.js'; +import { messageController } from '../controllers/message.controller.js'; +import { authMiddleware } from '../middlewares/authMiddleware.js'; + +export const messageRouter = new express.Router(); + +messageRouter.get( + '/rooms/:roomId/messages', + authMiddleware, + catchError(messageController.getAllMessages), +); + +messageRouter.post( + '/rooms/:roomId/messages', + authMiddleware, + catchError(messageController.createMessage), +); diff --git a/src/routes/refresh.route.js b/src/routes/refresh.route.js new file mode 100644 index 000000000..8a015ec27 --- /dev/null +++ b/src/routes/refresh.route.js @@ -0,0 +1,7 @@ +import express from 'express'; +import { catchError } from '../utils/catchError.js'; +import { refreshController } from '../controllers/refresh.controller.js'; + +export const refreshRouter = new express.Router(); + +refreshRouter.get('/', catchError(refreshController.refresh)); diff --git a/src/routes/rooms.route.js b/src/routes/rooms.route.js new file mode 100644 index 000000000..a670067fb --- /dev/null +++ b/src/routes/rooms.route.js @@ -0,0 +1,27 @@ +import express from 'express'; +import { catchError } from '../utils/catchError.js'; +import { roomController } from '../controllers/room.controller.js'; +import { authMiddleware } from '../middlewares/authMiddleware.js'; + +export const roomsRouter = new express.Router(); + +roomsRouter.get('/', authMiddleware, catchError(roomController.getAllRooms)); + +roomsRouter.get( + '/:roomId', + authMiddleware, + catchError(roomController.getRoomById), +); +roomsRouter.post('/', authMiddleware, catchError(roomController.createRoom)); + +roomsRouter.patch( + '/:roomId', + authMiddleware, + catchError(roomController.updateRoom), +); + +roomsRouter.delete( + '/:roomId', + authMiddleware, + catchError(roomController.deleteRoom), +); diff --git a/src/routes/user.route.js b/src/routes/user.route.js new file mode 100644 index 000000000..65aab0e1d --- /dev/null +++ b/src/routes/user.route.js @@ -0,0 +1,7 @@ +import express from 'express'; +import { catchError } from '../utils/catchError.js'; +import { userController } from '../controllers/user.controller.js'; + +export const userRouter = new express.Router(); + +userRouter.post('/', catchError(userController.createUser)); diff --git a/src/services/jwt.service.js b/src/services/jwt.service.js new file mode 100644 index 000000000..bc76f0fc3 --- /dev/null +++ b/src/services/jwt.service.js @@ -0,0 +1,36 @@ +import jwt from 'jsonwebtoken'; + +function sign(user) { + const token = jwt.sign(user, process.env.JWT_KEY); + + return token; +} + +function verify(token) { + try { + return jwt.verify(token, process.env.JWT_KEY); + } catch (error) { + return null; + } +} + +function signRefresh(user) { + const token = jwt.sign(user, process.env.JWT_REFRESH_KEY); + + return token; +} + +function verifyRefresh(token) { + try { + return jwt.verify(token, process.env.JWT_REFRESH_KEY); + } catch (error) { + return null; + } +} + +export const jwtService = { + sign, + verify, + signRefresh, + verifyRefresh, +}; diff --git a/src/services/message.service.js b/src/services/message.service.js new file mode 100644 index 000000000..2c5f3683d --- /dev/null +++ b/src/services/message.service.js @@ -0,0 +1,18 @@ +import { Message } from '../models/message.js'; +import { emmiter } from '../utils/emmiter.js'; + +async function createMessage(user, text, roomId) { + const message = await Message.create({ + text, + author: user.username, + time: new Date(), + userId: user.id, + roomId, + }); + + emmiter.emit('message', message); +} + +export const messageService = { + createMessage, +}; diff --git a/src/services/room.service.js b/src/services/room.service.js new file mode 100644 index 000000000..f193c55e5 --- /dev/null +++ b/src/services/room.service.js @@ -0,0 +1,45 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { Room } from '../models/room.js'; + +function getRoomById(roomId) { + return Room.findOne({ where: { id: roomId } }); +} + +async function createRoom(user, title, participants) { + const existTitle = await Room.findOne({ where: { title } }); + + if (existTitle) { + throw ApiError.badRequest('Room already exist'); + } + + await Room.create({ + title, + userId: user.id, + participants: participants + ? [user.username, ...participants] + : [user.username], + }); +} + +async function updateRoom(user, room, title, participants) { + if (title) { + room.title = title; + } + + if (participants) { + room.participants = [user.username, ...participants]; + } + + await room.save(); +} + +function deleteRoom(roomId) { + return Room.destroy({ where: { id: roomId } }); +} + +export const roomService = { + getRoomById, + createRoom, + updateRoom, + deleteRoom, +}; diff --git a/src/services/token.service.js b/src/services/token.service.js new file mode 100644 index 000000000..25aa3c362 --- /dev/null +++ b/src/services/token.service.js @@ -0,0 +1,22 @@ +import { Token } from '../models/token.js'; + +async function save(userId, newToken) { + const token = await Token.findOne({ where: { userId } }); + + if (!token) { + await Token.create({ userId, refreshToken: newToken }); + } else { + token.refreshToken = newToken; + + await token.save(); + } +} + +function getByToken(refreshToken) { + return Token.findOne({ where: { refreshToken } }); +} + +export const tokenService = { + save, + getByToken, +}; diff --git a/src/services/user.service.js b/src/services/user.service.js new file mode 100644 index 000000000..2bdea9c3f --- /dev/null +++ b/src/services/user.service.js @@ -0,0 +1,47 @@ +import { ApiError } from '../exeptions/api.error.js'; +import { User } from '../models/user.js'; +import { jwtService } from './jwt.service.js'; + +function findByUserName(username) { + return User.findOne({ where: { username } }); +} + +function normalize({ id, username }) { + return { id, username }; +} + +async function createUser(username) { + const existUser = await User.findOne({ where: { username } }); + + if (existUser) { + throw ApiError.badRequest('User already exist'); + } + + await User.create({ username }); +} + +async function getUser(req) { + const authorization = req.headers['authorization'] || ''; + const [, token] = authorization.split(' '); + + if (!authorization || !token) { + throw ApiError.unauthorized(); + } + + const userData = jwtService.verify(token); + + if (!userData) { + throw ApiError.notFound(); + } + + const user = await findByUserName(userData.username); + + return user; +} + +export const userService = { + findByUserName, + normalize, + createUser, + getUser, +}; diff --git a/src/utils/catchError.js b/src/utils/catchError.js new file mode 100644 index 000000000..0e1e7d8f5 --- /dev/null +++ b/src/utils/catchError.js @@ -0,0 +1,9 @@ +export const catchError = (action) => { + return async function (req, res, next) { + try { + await action(req, res, next); + } catch (error) { + next(error); + } + }; +}; diff --git a/src/utils/db.js b/src/utils/db.js new file mode 100644 index 000000000..d3b20989a --- /dev/null +++ b/src/utils/db.js @@ -0,0 +1,11 @@ +import { Sequelize } from 'sequelize'; + +const { DB_HOST, DB_USER, DB_PASSWORD, DB_DATABASE } = process.env; + +export const client = new Sequelize({ + host: DB_HOST || 'localhost', + username: DB_USER || 'postgres', + password: DB_PASSWORD || '12345qwert', + database: DB_DATABASE || 'postgres', + dialect: 'postgres', +}); diff --git a/src/utils/emmiter.js b/src/utils/emmiter.js new file mode 100644 index 000000000..966cd3d4f --- /dev/null +++ b/src/utils/emmiter.js @@ -0,0 +1,3 @@ +import { EventEmitter } from 'events'; + +export const emmiter = new EventEmitter();