diff --git a/.gitignore b/.gitignore index 30ac249..0f87f4d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ node_modules/ dist/ build/ -infra/ .env .vscode .eslintcache diff --git a/frontend/src/shared/infra/services/BaseAPI.tsx b/frontend/src/shared/infra/services/BaseAPI.tsx new file mode 100644 index 0000000..797ff72 --- /dev/null +++ b/frontend/src/shared/infra/services/BaseAPI.tsx @@ -0,0 +1,86 @@ +import axios, { AxiosInstance } from "axios"; +import { apiConfig } from "../../../config/api"; + +export abstract class BaseAPI { + protected baseUrl: string; + private axiosInstance: AxiosInstance | any = null; + + constructor() { + this.baseUrl = apiConfig.baseUrl; + this.axiosInstance = axios.create({}); + this.enableInterceptors(); + } + + private enableInterceptors(): void { + this.axiosInstance.interceptors.response.use( + this.getSuccessResponseHandler(), + this.getErrorResponseHandler() + ); + } + + private getSuccessResponseHandler() { + return (response: any) => { + return response; + }; + } + + private getErrorResponseHandler() { + return async (error: any) => { + return Promise.reject({ ...error }); + }; + } + + protected get(url: string, params?: any, headers?: any): Promise { + return this.axiosInstance({ + method: "GET", + url: `${this.baseUrl}${url}`, + params: params ? params : null, + headers: headers ? headers : null, + }); + } + + protected post( + url: string, + data?: any, + params?: any, + headers?: any + ): Promise { + return this.axiosInstance({ + method: "POST", + url: `${this.baseUrl}${url}`, + data: data ? data : null, + params: params ? params : null, + headers: headers ? headers : null, + }); + } + + protected put( + url: string, + data?: any, + params?: any, + headers?: any + ): Promise { + return this.axiosInstance({ + method: "PUT", + url: `${this.baseUrl}${url}`, + data: data ? data : null, + params: params ? params : null, + headers: headers ? headers : null, + }); + } + + protected delete( + url: string, + data?: any, + params?: any, + headers?: any + ): Promise { + return this.axiosInstance({ + method: "DELETE", + url: `${this.baseUrl}${url}`, + data: data ? data : null, + params: params ? params : null, + headers: headers ? headers : null, + }); + } +} diff --git a/server/package.json b/server/package.json index aaec501..4c9a4a3 100644 --- a/server/package.json +++ b/server/package.json @@ -1,8 +1,7 @@ { - "name": "nettu-meeting-server", + "name": "nettu-meet-server", "version": "1.0.0", "description": "", - "main": "index.js", "scripts": { "start": "./integrations/start.sh", "start:prod": "ts-node ./src/index.ts", diff --git a/server/src/modules/account/infra/http/routes/index.ts b/server/src/modules/account/infra/http/routes/index.ts new file mode 100644 index 0000000..d800047 --- /dev/null +++ b/server/src/modules/account/infra/http/routes/index.ts @@ -0,0 +1,12 @@ +import express from 'express'; +import { middleware } from '../../../../../shared/infra/http'; +import { createAccountController } from '../../../useCases/createAccount'; +import { updateAccountController } from '../../../useCases/updateAccount'; + +const accountRouter = express.Router(); + +accountRouter.post('/', (req, res) => createAccountController.execute(req, res)); + +accountRouter.put('/', middleware.ensureAccountAdmin(), (req, res) => updateAccountController.execute(req, res)); + +export { accountRouter }; diff --git a/server/src/modules/chat/infra/http/routes/index.ts b/server/src/modules/chat/infra/http/routes/index.ts new file mode 100644 index 0000000..0ef8f04 --- /dev/null +++ b/server/src/modules/chat/infra/http/routes/index.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import { getChatController } from '../../../useCases/getChat'; + +const chatRouter = express.Router(); + +chatRouter.get('/:meetingId/:chatId', (req, res) => getChatController.execute(req, res)); + +export { chatRouter }; diff --git a/server/src/modules/meeting/infra/http/routes/index.ts b/server/src/modules/meeting/infra/http/routes/index.ts new file mode 100644 index 0000000..d64519c --- /dev/null +++ b/server/src/modules/meeting/infra/http/routes/index.ts @@ -0,0 +1,81 @@ +import express from 'express'; +import { Socket } from 'socket.io'; +import { middleware } from '../../../../../shared/infra/http'; +import { io } from '../../../../../shared/infra/http/app'; +import { sendChatMessageController } from '../../../../chat/useCases/sendChatMessage'; +import { onPeerJoinedMeeting } from '../../../services/mediasoup'; +import { createCanvasController } from '../../../useCases/createCanvas'; +import { createDemoMeetingController } from '../../../useCases/createDemoMeeting'; +import { createMeetingController } from '../../../useCases/createMeeting'; +import { createResourceController } from '../../../useCases/createResource'; +import { deleteMeetingController } from '../../../useCases/deleteMeeting'; +import { deleteResourceController } from '../../../useCases/deleteResource'; +import { getCanvasController } from '../../../useCases/getCanvas'; +import { getMeetingController } from '../../../useCases/getMeeting'; +import { setActiveCanvasController } from '../../../useCases/setActiveCanvas'; +import { setCanvasDataController } from '../../../useCases/setCanvasData'; +import { updateMeetingController } from '../../../useCases/updateMeeting'; + +const meetingRouter = express.Router(); + +meetingRouter.post('/', middleware.ensureAccountAdmin(), (req, res) => createMeetingController.execute(req, res)); + +meetingRouter.post('/demo', (req, res) => createDemoMeetingController.execute(req, res)); + +meetingRouter.get('/:meetingId', (req, res) => getMeetingController.execute(req, res)); + +meetingRouter.put('/:meetingId', middleware.ensureAccountAdmin(), (req, res) => + updateMeetingController.execute(req, res), +); + +meetingRouter.delete('/:meetingId', middleware.ensureAccountAdmin(), (req, res) => + deleteMeetingController.execute(req, res), +); + +meetingRouter.post('/:meetingId/canvas', (req, res) => createCanvasController.execute(req, res)); + +meetingRouter.post('/:meetingId/resource', (req, res) => createResourceController.execute(req, res)); + +meetingRouter.delete('/:meetingId/resource/:resourceId', (req, res) => deleteResourceController.execute(req, res)); + +meetingRouter.get('/canvas/:canvasId', (req, res) => getCanvasController.execute(req, res)); + +const meetingSocketHandler = (socket: Socket) => { + socket.on('join-room', async (meetingId) => { + console.log('socket: ' + socket.id + ', joined room: ' + meetingId); + + socket.join(meetingId); + socket.broadcast.to(meetingId).emit('user-connected', socket.id); + + onPeerJoinedMeeting(socket, meetingId); + + socket.on('canvas-update', (canvasId, event) => { + setCanvasDataController.executeImpl(socket, { + canvasId, + meetingId, + event, + }); + }); + + socket.on('active-canvas-change', (canvasId) => { + setActiveCanvasController.executeImpl(socket, { + canvasId, + meetingId, + }); + }); + + socket.on('chat-message', (chatId, event) => { + sendChatMessageController.executeImpl(socket, { + meetingId, + chatId, + content: event.content, + }); + }); + + socket.on('new-resource', (data) => { + io.to(meetingId).emit('new-resource', data); + }); + }); +}; + +export { meetingRouter, meetingSocketHandler }; diff --git a/server/src/modules/user/infra/http/routes/index.ts b/server/src/modules/user/infra/http/routes/index.ts new file mode 100644 index 0000000..6c1a253 --- /dev/null +++ b/server/src/modules/user/infra/http/routes/index.ts @@ -0,0 +1,11 @@ +import express from 'express'; +import { createEmailVerificationController } from '../../../useCases/createEmailVerificationCode'; +import { validateEmailVerificationController } from '../../../useCases/validateEmailVerificationCode'; + +const userRouter = express.Router(); + +userRouter.post('/email-verification', (req, res) => createEmailVerificationController.execute(req, res)); + +userRouter.post('/email-verification/validate', (req, res) => validateEmailVerificationController.execute(req, res)); + +export { userRouter }; diff --git a/server/src/shared/infra/db/BaseRepo.ts b/server/src/shared/infra/db/BaseRepo.ts new file mode 100644 index 0000000..d398f6a --- /dev/null +++ b/server/src/shared/infra/db/BaseRepo.ts @@ -0,0 +1,19 @@ +import { Collection, Db } from 'mongodb'; +import { UniqueEntityID } from '../../domain/UniqueEntityID'; + +export interface BaseRepoEntity { + _id: UniqueEntityID; + [key: string]: any; +} + +export abstract class BaseRepo { + protected db!: Db; + protected collection!: Collection; + + constructor(db: Promise, collectionName: string) { + db.then((_db) => { + this.db = _db; + this.collection = _db.collection(collectionName); + }); + } +} diff --git a/server/src/shared/infra/db/connection.ts b/server/src/shared/infra/db/connection.ts new file mode 100644 index 0000000..be36d2b --- /dev/null +++ b/server/src/shared/infra/db/connection.ts @@ -0,0 +1,16 @@ +import { MongoClient, Db } from 'mongodb'; + +const dbName = process.env.MONGODB_NAME as string; + +export const mongoDBClient = new MongoClient(process.env.MONGODB_CONNECTION_STRING as string, { + useNewUrlParser: true, + useUnifiedTopology: true, +}); + +export const _db_connect_promise = mongoDBClient + .connect() + .then((_) => mongoDBClient.db(dbName)) + .catch((e) => { + console.log('db error'); + console.log(e); + }) as Promise; diff --git a/server/src/shared/infra/http/api/v1.ts b/server/src/shared/infra/http/api/v1.ts new file mode 100644 index 0000000..e65ff65 --- /dev/null +++ b/server/src/shared/infra/http/api/v1.ts @@ -0,0 +1,33 @@ +import express from 'express'; +import { Socket } from 'socket.io'; +import { docsRouter } from '../../../../docs/spec'; +import { chatRouter } from '../../../../modules/chat/infra/http/routes'; +import { accountRouter } from '../../../../modules/account/infra/http/routes'; +import { meetingRouter, meetingSocketHandler } from '../../../../modules/meeting/infra/http/routes'; +import { signalingRouter } from '../../../../modules/meeting/services/mediasoup'; +import { userRouter } from '../../../../modules/user/infra/http/routes'; + +const v1Router = express.Router(); + +v1Router.get('/', (req, res) => { + return res.json({ message: 'Ofc we are up ...' }); +}); + +v1Router.use('/account', accountRouter); +v1Router.use('/meeting', meetingRouter); +v1Router.use('/chat', chatRouter); +v1Router.use('/user', userRouter); +v1Router.use('/signaling', signalingRouter); +v1Router.use('/docs', docsRouter); + +const v1SocketHandler = (socket: Socket) => { + console.log('user connected: ' + socket.id); + + meetingSocketHandler(socket); + + socket.on('disconnect', () => { + socket.broadcast.emit('user-disconnected', socket.id); + }); +}; + +export { v1Router, v1SocketHandler }; diff --git a/server/src/shared/infra/http/app.ts b/server/src/shared/infra/http/app.ts new file mode 100644 index 0000000..b2d8086 --- /dev/null +++ b/server/src/shared/infra/http/app.ts @@ -0,0 +1,74 @@ +import fs from 'fs'; +import bodyParser from 'body-parser'; +import compression from 'compression'; +import cors from 'cors'; +import http from 'http'; +import https from 'https'; +import express from 'express'; +import helmet from 'helmet'; +import morgan from 'morgan'; +import { Server, Socket } from 'socket.io'; +import { createAdapter } from 'socket.io-redis'; +import { v1Router, v1SocketHandler } from './api/v1'; +import { redisConnection } from '../../services'; +import { createDefaultAccountController } from '../../../modules/account/useCases/createDefaultAccount'; +import { _db_connect_promise } from '../db/connection'; + +const isProduction = process.env.ENVIRONMENT === 'prod'; + +const corsConfig = { + origin: isProduction ? '*' : '*', +}; + +const app = express(); + +const createServer = () => { + if (isProduction) { + // Certificate + const domain = process.env['SERVER_DOMAIN_NAME']; + const privateKey = fs.readFileSync(`/etc/letsencrypt/live/${domain}/privkey.pem`, 'utf8'); + const certificate = fs.readFileSync(`/etc/letsencrypt/live/${domain}/cert.pem`, 'utf8'); + const ca = fs.readFileSync(`/etc/letsencrypt/live/${domain}/chain.pem`, 'utf8'); + const credentials = { + key: privateKey, + cert: certificate, + ca: ca, + }; + return https.createServer(credentials, app); + } else { + return http.createServer(app); + } +}; + +const server = createServer(); + +const port = process.env.PORT || (isProduction ? 443 : 5000); + +export const io = new Server(server, { + path: '/api/v1/ws', + adapter: createAdapter({ + pubClient: redisConnection, + subClient: redisConnection.duplicate(), + }), + cors: corsConfig, +}); + +io.on('connection', (socket: Socket) => { + v1SocketHandler(socket); +}); + +app.use(bodyParser.json()); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(cors(corsConfig)); +app.use(compression()); +app.use(helmet()); +app.use(morgan('combined')); +app.use('/api/v1', v1Router); + +server.listen(port, async () => { + console.log(`[App]: Listening on port ${port}`); + + // Setup default account if provided + await _db_connect_promise; + createDefaultAccountController.execute(); +}); diff --git a/server/src/shared/infra/http/index.ts b/server/src/shared/infra/http/index.ts new file mode 100644 index 0000000..f65d34c --- /dev/null +++ b/server/src/shared/infra/http/index.ts @@ -0,0 +1,6 @@ +import { accountRepo } from '../../../modules/account/repos'; +import { Middleware } from './utils/Middleware'; + +const middleware = new Middleware(accountRepo); + +export { middleware }; diff --git a/server/src/shared/infra/http/models/BaseController.ts b/server/src/shared/infra/http/models/BaseController.ts new file mode 100644 index 0000000..c6dda1f --- /dev/null +++ b/server/src/shared/infra/http/models/BaseController.ts @@ -0,0 +1,121 @@ +import * as express from 'express'; +import Joi from 'joi'; +import { Account } from '../../../../modules/account/domain/account'; +import { DecodedExpressRequest } from './decodedRequest'; + +// eslint-disable-next-line @typescript-eslint/ban-types +export interface NettuAppRequest { + body: B; + pathParams: P; + account?: Account; +} + +// eslint-disable-next-line @typescript-eslint/ban-types +export abstract class BaseController { + private bodySchema: Joi.ObjectSchema; + private pathParamsSchema: Joi.ObjectSchema

; + + constructor(body: Joi.ObjectSchema | null, pathParams: Joi.ObjectSchema

| null) { + this.bodySchema = body ? body : Joi.object({}); + this.pathParamsSchema = pathParams ? pathParams : Joi.object({}); + } + + protected abstract executeImpl(req: NettuAppRequest, res: NettuAppResponse): Promise; + + public async execute(req: express.Request, _res: express.Response): Promise { + const res = new NettuAppResponse(_res); + const reqBody = req.body != null ? req.body : {}; + const reqPathParams = req.params != null ? req.params : {}; + + const bodySchemaValidationRes = this.bodySchema.validate(reqBody); + if (bodySchemaValidationRes.error) { + return res.badClientData(bodySchemaValidationRes.error.message); + } + + const pathParamsSchemaValidationRes = this.pathParamsSchema.validate(reqPathParams); + if (pathParamsSchemaValidationRes.error) { + return res.badClientData(pathParamsSchemaValidationRes.error.message); + } + + let account; + const maybeDecodedReq = req as DecodedExpressRequest; + if (maybeDecodedReq.decoded && maybeDecodedReq.decoded.account) { + account = maybeDecodedReq.decoded.account; + } + + const nettuReq: NettuAppRequest = { + body: bodySchemaValidationRes.value, + pathParams: pathParamsSchemaValidationRes.value, + account, + }; + try { + this.executeImpl(nettuReq, res); + } catch (error) { + console.log(`[BaseController]: Uncaught controller error`); + console.log(error); + res.fail(); + } + } +} + +export class NettuAppResponse { + private res: express.Response; + private sent: boolean; + + constructor(res: express.Response) { + this.sent = false; + this.res = res; + } + + private jsonResponse(code: number, dto: any): void { + if (this.sent) { + throw new Error('Cannot send response twice'); + } + this.sent = true; + this.res.status(code).json(dto); + } + + public ok(dto?: T): void { + return this.jsonResponse(200, dto); + } + + public created(dto?: T): void { + return this.jsonResponse(201, dto); + } + + public badClientData(message?: string): void { + return this.jsonResponse(400, { + message: message ? message : 'Bad client request', + }); + } + + public unauthorized(message?: string): void { + return this.jsonResponse(401, { + message: message ? message : 'Unauthorized', + }); + } + + public forbidden(message?: string): void { + return this.jsonResponse(403, { + message: message ? message : 'Forbidden', + }); + } + + public notFound(message?: string): void { + return this.jsonResponse(404, { + message: message ? message : 'Not found', + }); + } + + public conflict(message?: string): void { + return this.jsonResponse(409, { + message: message ? message : 'Conflict', + }); + } + + public fail(): void { + return this.jsonResponse(500, { + message: 'An unexpected error occurred', + }); + } +} diff --git a/server/src/shared/infra/http/models/BaseWSController.ts b/server/src/shared/infra/http/models/BaseWSController.ts new file mode 100644 index 0000000..c2c097b --- /dev/null +++ b/server/src/shared/infra/http/models/BaseWSController.ts @@ -0,0 +1,28 @@ +import Joi from 'joi'; +import { Socket } from 'socket.io'; + +export abstract class BaseWSController { + private reqSchema: Joi.Schema; + + constructor(req: Joi.Schema) { + this.reqSchema = req; + } + + protected abstract executeImpl(socket: Socket, payload: T): Promise; + + public async execute(socket: Socket, req: any): Promise { + req = req != null ? req : {}; + const schemaValidationRes = this.reqSchema.validate(req); + if (schemaValidationRes.error) { + return; + } + const payload = schemaValidationRes.value as T; + + try { + await this.executeImpl(socket, payload); + } catch (err) { + console.log(`[BaseController]: Uncaught controller error`); + console.log(err); + } + } +} diff --git a/server/src/shared/infra/http/models/decodedRequest.ts b/server/src/shared/infra/http/models/decodedRequest.ts new file mode 100644 index 0000000..d7551a1 --- /dev/null +++ b/server/src/shared/infra/http/models/decodedRequest.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import { Account } from '../../../../modules/account/domain/account'; + +export interface DecodedExpressRequest extends express.Request { + decoded: { + account: Account; + }; +} diff --git a/server/src/shared/infra/http/utils/Middleware.ts b/server/src/shared/infra/http/utils/Middleware.ts new file mode 100644 index 0000000..8e0169e --- /dev/null +++ b/server/src/shared/infra/http/utils/Middleware.ts @@ -0,0 +1,39 @@ +import { Request, Response, NextFunction } from 'express'; +import { IAccountRepo } from '../../../../modules/account/repos/accountRepo'; +import { DecodedExpressRequest } from '../models/decodedRequest'; + +export class Middleware { + private accountRepo: IAccountRepo; + + constructor(accountRepo: IAccountRepo) { + this.accountRepo = accountRepo; + } + + private endRequest(status: 400 | 401 | 403, message: string, res: any): any { + return res.status(status).send({ message }); + } + + public ensureAccountAdmin() { + return async (req: Request, res: Response, next: NextFunction) => { + const apiKey = req.headers['authorization'] + ? req.headers['authorization'].replace('Bearer', '').trim() + : ''; + + if (!apiKey) { + this.endRequest(403, 'Invalid secret api key provided', res); + } + + const account = await this.accountRepo.getAccountBySecretKey(apiKey); + if (!account) { + this.endRequest(403, 'Invalid secret api key provided', res); + } + + (req as DecodedExpressRequest).decoded = { + ...(req as DecodedExpressRequest).decoded, + account: account!, + }; + + return next(); + }; + } +}