diff --git a/packages/message_server/.env-example b/packages/message_server/.env-example new file mode 100755 index 00000000..d5dd5e8e --- /dev/null +++ b/packages/message_server/.env-example @@ -0,0 +1,2 @@ +MAIN_API_URL="https://api.example.com" +SERVER_TOKEN="ABCDEFGH12345678" \ No newline at end of file diff --git a/packages/message_server/Dockerfile b/packages/message_server/Dockerfile new file mode 100755 index 00000000..155720db --- /dev/null +++ b/packages/message_server/Dockerfile @@ -0,0 +1,19 @@ +FROM node:16-bullseye + +RUN apt update +RUN apt install python -y +RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app + +WORKDIR /home/node/app +USER node + +EXPOSE 3010 + +COPY package.json ./ +COPY --chown=node:node . . + +RUN chmod -R 777 /home/node/app +RUN npm install +RUN npm run build + +CMD ["node", "/home/node/app/dist/index.js"] \ No newline at end of file diff --git a/packages/message_server/package.json b/packages/message_server/package.json new file mode 100755 index 00000000..87b4f750 --- /dev/null +++ b/packages/message_server/package.json @@ -0,0 +1,30 @@ +{ + "name": "@comty/message_server", + "version": "0.35.1", + "main": "dist/index.js", + "scripts": { + "build": "corenode-cli build", + "dev": "nodemon --ignore dist/ --exec corenode-node ./src/index.js" + }, + "license": "MIT", + "dependencies": { + "@foxify/events": "^2.1.0", + "axios": "^1.2.1", + "bcrypt": "5.0.1", + "corenode": "0.28.26", + "cors": "^2.8.5", + "dotenv": "^16.0.3", + "express": "^4.18.2", + "jsonwebtoken": "8.5.1", + "luxon": "^3.0.4", + "moment": "2.29.4", + "moment-timezone": "0.5.37", + "morgan": "^1.10.0", + "nanoid": "3.2.0", + "socket.io": "^4.5.4" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "nodemon": "^2.0.15" + } +} \ No newline at end of file diff --git a/packages/message_server/src/api.js b/packages/message_server/src/api.js new file mode 100755 index 00000000..2440f4f9 --- /dev/null +++ b/packages/message_server/src/api.js @@ -0,0 +1,229 @@ +import express from "express" +import http from "http" +import cors from "cors" +import morgan from "morgan" +import socketio from "socket.io" +import EventEmitter from "@foxify/events" +import jwt from "jsonwebtoken" +import axios from "axios" + +import routes from "./routes" + +const mainAPI = axios.create({ + baseURL: process.env.MAIN_API_URL ?? "http://localhost:3000", + headers: { + "server-token": process.env.SERVER_TOKEN, + } +}) + +class TextRoomServer { + constructor(server, options = {}) { + this.io = socketio(server, { + cors: { + origin: "*", + methods: ["GET", "POST"], + credentials: true, + } + }) + + this.limitations = { + maxMessageLength: 540, + ...options.limitations, + } + } + + connectionPool = [] + + roomEventsHandlers = { + "send:message": (socket, payload) => { + const { connectedRoom } = socket + let { message } = payload + + if (message.length > this.limitations.maxMessageLength) { + message = message.substring(0, this.limitations.maxMessageLength) + } + + this.io.to(connectedRoom).emit("message", { + timestamp: payload.timestamp ?? Date.now(), + content: String(message), + user: { + username: socket.userData.username, + fullName: socket.userData.fullName, + avatar: socket.userData.avatar, + }, + }) + } + } + + initializeSocketIO = () => { + this.io.use(async (socket, next) => { + const token = socket.handshake.auth.token + + if (!token) { + return next(new Error(`auth:token_missing`)) + } + + const session = await mainAPI.post("/validate_session", { + session: token + }) + .then((res) => { + return res.data + }) + .catch((err) => { + return false + }) + + if (!session || !session?.valid) { + return next(new Error(`auth:token_invalid`)) + } + + const { data: userData } = await mainAPI.get("/user/public_data", { + params: { + username: session.username + } + }).catch((err) => { + return null + }) + + if (!userData) { + return next(new Error(`auth:user_failed`)) + } + + try { + // try to decode the token and get the user's username + const decodedToken = jwt.decode(token) + + socket.userData = userData + socket.token = token + socket.decodedToken = decodedToken + } + catch (err) { + return next(new Error(`auth:decode_failed`)) + } + + console.log(`[${socket.id}] connected`) + + next() + }) + + this.io.on("connection", (socket) => { + socket.on("join", (...args) => this.handleClientJoin(socket, ...args)) + + socket.on("disconnect", () => { + this.handleClientDisconnect(socket) + }) + }) + } + + async handleClientJoin(socket, payload, cb) { + const { room } = payload + + socket.connectedRoom = room + + const pool = await this.attachClientToPool(socket, room).catch((err) => { + cb(err) + return null + }) + + if (!pool) return + + console.log(`[${socket.id}] joined room [${room}]`) + + socket.join(room) + + Object.keys(this.roomEventsHandlers).forEach((event) => { + socket.on(event, (...args) => this.roomEventsHandlers[event](socket, ...args)) + }) + + const roomConnections = this.connectionPool.filter((client) => client.room === room).length + + cb(null, { + roomConnections, + limitations: this.limitations, + }) + } + + handleClientDisconnect(socket) { + const index = this.connectionPool.findIndex((client) => client.id === socket.id) + + if (index === -1) return + + return this.connectionPool.splice(index, 1) + } + + async attachClientToPool(socket, room) { + // TODO: check if user can join room or is privated + + if (!room) { + throw new Error(`room:invalid`) + } + + return this.connectionPool.push({ + id: socket.id, + room, + socket + }) + } +} + +export default class Server { + constructor(options = {}) { + this.app = express() + this.httpServer = http.createServer(this.app) + + this.textRoomServer = new TextRoomServer(this.httpServer) + + this.options = { + listenPort: process.env.PORT || 3020, + ...options + } + } + + eventBus = global.eventBus = new EventEmitter() + + initialize = async () => { + this.app.use(cors()) + this.app.use(express.json({ extended: false })) + this.app.use(express.urlencoded({ extended: true })) + + // Use logger if not in production + if (!process.env.NODE_ENV === "production") { + this.app.use(morgan("dev")) + } + + await this.textRoomServer.initializeSocketIO() + + await this.registerBaseRoute() + await this.registerRoutes() + + await this.httpServer.listen(this.options.listenPort) + + return { + listenPort: this.options.listenPort, + } + } + + async registerBaseRoute() { + await this.app.get("/", async (req, res) => { + return res.json({ + uptimeMinutes: Math.floor(process.uptime() / 60), + }) + }) + } + + registerRoutes() { + routes.forEach((route) => { + const order = [] + + if (route.middlewares) { + route.middlewares.forEach((middleware) => { + order.push(middleware) + }) + } + + order.push(route.routes) + + this.app.use(route.use, ...order) + }) + } +} \ No newline at end of file diff --git a/packages/message_server/src/index.js b/packages/message_server/src/index.js new file mode 100755 index 00000000..8363cf98 --- /dev/null +++ b/packages/message_server/src/index.js @@ -0,0 +1,49 @@ +require("dotenv").config() + +// patches +const { Buffer } = require("buffer") + +global.b64Decode = (data) => { + return Buffer.from(data, "base64").toString("utf-8") +} +global.b64Encode = (data) => { + return Buffer.from(data, "utf-8").toString("base64") +} + +Array.prototype.updateFromObjectKeys = function (obj) { + this.forEach((value, index) => { + if (obj[value] !== undefined) { + this[index] = obj[value] + } + }) + + return this +} + +global.toBoolean = (value) => { + if (typeof value === "boolean") { + return value + } + + if (typeof value === "string") { + return value.toLowerCase() === "true" + } + + return false +} + +import pkg from "../package.json" +import API from "./api" + +async function main() { + const api = new API() + + console.log(`\nā–¶ļø Initializing ${pkg.name} ...\n`) + const init = await api.initialize() + + console.log(`\nšŸš€ ${pkg.name} v${pkg.version} is running on port ${init.listenPort}.\n`) +} + +main().catch((error) => { + console.error(`šŸ†˜ [FATAL ERROR] >`, error) +}) \ No newline at end of file diff --git a/packages/message_server/src/middlewares/errorHandler/index.js b/packages/message_server/src/middlewares/errorHandler/index.js new file mode 100755 index 00000000..d8e53df2 --- /dev/null +++ b/packages/message_server/src/middlewares/errorHandler/index.js @@ -0,0 +1,5 @@ +export const errorHandler = (error, req, res, next) => { + res.json({ error: error.message }) +} + +export default errorHandler \ No newline at end of file diff --git a/packages/message_server/src/middlewares/hasPermissions/index.js b/packages/message_server/src/middlewares/hasPermissions/index.js new file mode 100755 index 00000000..2cc254a8 --- /dev/null +++ b/packages/message_server/src/middlewares/hasPermissions/index.js @@ -0,0 +1,30 @@ +export const hasPermissions = (req, res, next) => { + if (typeof (req.userData) == "undefined") { + return res.status(403).json(`User data is not available, please ensure if you are authenticated`) + } + + const { _id, username, roles } = req.userData + const { permissions } = req.body + + req.userPermissions = roles + + let check = [] + + if (Array.isArray(permissions)) { + check = permissions + } else { + check.push(permissions) + } + + if (check.length > 0) { + check.forEach((role) => { + if (!roles.includes(role)) { + return res.status(403).json(`${username} not have permissions ${permissions}`) + } + }) + } + + next() +} + +export default hasPermissions diff --git a/packages/message_server/src/middlewares/index.js b/packages/message_server/src/middlewares/index.js new file mode 100755 index 00000000..d22af7db --- /dev/null +++ b/packages/message_server/src/middlewares/index.js @@ -0,0 +1,12 @@ +// const fileUpload = require("@nanoexpress/middleware-file-upload/cjs")() + +export { default as withAuthentication } from "./withAuthentication" +export { default as withOptionalAuthentication } from "./withOptionalAuthentication" + +export { default as errorHandler } from "./errorHandler" +export { default as hasPermissions } from "./hasPermissions" +export { default as roles } from "./roles" +export { default as onlyAdmin } from "./onlyAdmin" +export { default as permissions } from "./permissions" + +// export { fileUpload as fileUpload } \ No newline at end of file diff --git a/packages/message_server/src/middlewares/onlyAdmin/index.js b/packages/message_server/src/middlewares/onlyAdmin/index.js new file mode 100755 index 00000000..730faba8 --- /dev/null +++ b/packages/message_server/src/middlewares/onlyAdmin/index.js @@ -0,0 +1,7 @@ +export default (req, res, next) => { + if (!req.user.roles.includes("admin")) { + return res.status(403).json({ error: "To make this request it is necessary to have administrator permissions" }) + } + + next() +} \ No newline at end of file diff --git a/packages/message_server/src/middlewares/permissions/index.js b/packages/message_server/src/middlewares/permissions/index.js new file mode 100755 index 00000000..bff9e765 --- /dev/null +++ b/packages/message_server/src/middlewares/permissions/index.js @@ -0,0 +1,39 @@ +import { Config } from "../../models" + +export default (req, res, next) => { + const requestedPath = `${req.method.toLowerCase()}${req.path.toLowerCase()}` + + Config.findOne({ key: "permissions" }, undefined, { + lean: true, + }).then(({ value }) => { + req.assertedPermissions = [] + + const pathRoles = value.pathRoles ?? {} + + if (typeof pathRoles[requestedPath] === "undefined") { + console.warn(`[Permissions] No permissions defined for path ${requestedPath}`) + return next() + } + + const requiredRoles = Array.isArray(pathRoles[requestedPath]) ? pathRoles[requestedPath] : [pathRoles[requestedPath]] + + requiredRoles.forEach((role) => { + if (req.user.roles.includes(role)) { + req.assertedPermissions.push(role) + } + }) + + if (req.user.roles.includes("admin")) { + req.assertedPermissions.push("admin") + } + + if (req.assertedPermissions.length === 0 && !req.user.roles.includes("admin")) { + return res.status(403).json({ + error: "forbidden", + message: "You don't have permission to access this resource", + }) + } + + next() + }) +} \ No newline at end of file diff --git a/packages/message_server/src/middlewares/roles/index.js b/packages/message_server/src/middlewares/roles/index.js new file mode 100755 index 00000000..16c9e3c3 --- /dev/null +++ b/packages/message_server/src/middlewares/roles/index.js @@ -0,0 +1,19 @@ +export default (req, res, next) => { + req.isAdmin = () => { + if (req.user.roles.includes("admin")) { + return true + } + + return false + } + + req.hasRole = (role) => { + if (req.user.roles.includes(role)) { + return true + } + + return false + } + + next() +} \ No newline at end of file diff --git a/packages/message_server/src/middlewares/withAuthentication/index.js b/packages/message_server/src/middlewares/withAuthentication/index.js new file mode 100755 index 00000000..b3dcf481 --- /dev/null +++ b/packages/message_server/src/middlewares/withAuthentication/index.js @@ -0,0 +1,83 @@ +import { Session, User } from "../../models" +import { Token } from "../../lib" +import jwt from "jsonwebtoken" + +export default (req, res, next) => { + function reject(description) { + return res.status(403).json({ error: `${description ?? "Invalid session"}` }) + } + + const authHeader = req.headers?.authorization?.split(" ") + + if (authHeader && authHeader[0] === "Bearer") { + const token = authHeader[1] + let decoded = null + + try { + decoded = jwt.decode(token) + } catch (error) { + console.error(error) + } + + if (!decoded) { + return reject("Cannot decode token") + } + + jwt.verify(token, global.jwtStrategy.secretOrKey, async (err) => { + const sessions = await Session.find({ user_id: decoded.user_id }) + const currentSession = sessions.find((session) => session.token === token) + + if (!currentSession) { + return reject("Cannot find session") + } + + const userData = await User.findOne({ _id: currentSession.user_id }).select("+refreshToken") + + if (!userData) { + return res.status(404).json({ error: "No user data found" }) + } + + // if cannot verify token, start regeneration process + if (err) { + // first check if token is only expired, if is corrupted, reject + if (err.name !== "TokenExpiredError") { + return reject("Invalid token, cannot regenerate") + } + + let regenerationToken = null + + // check if this expired token has a regeneration token associated + const associatedRegenerationToken = await Token.getRegenerationToken(token) + + if (associatedRegenerationToken) { + regenerationToken = associatedRegenerationToken.refreshToken + } else { + // create a new regeneration token with the expired token + regenerationToken = await Token.createNewRegenerationToken(token).catch((error) => { + // in case of error, reject + reject(error.message) + + return null + }) + } + + if (!regenerationToken) return + + // now send the regeneration token to the client (start Expired Exception Event [EEE]) + return res.status(401).json({ + error: "Token expired", + refreshToken: regenerationToken.refreshToken, + }) + } + + req.user = userData + req.jwtToken = token + req.decodedToken = decoded + req.currentSession = currentSession + + return next() + }) + } else { + return reject("Missing token header") + } +} diff --git a/packages/message_server/src/middlewares/withOptionalAuthentication/index.js b/packages/message_server/src/middlewares/withOptionalAuthentication/index.js new file mode 100755 index 00000000..4a03cd05 --- /dev/null +++ b/packages/message_server/src/middlewares/withOptionalAuthentication/index.js @@ -0,0 +1,9 @@ +import withAuthentication from "../withAuthentication" + +export default (req, res, next) => { + if (req.headers?.authorization) { + withAuthentication(req, res, next) + } else { + next() + } +} \ No newline at end of file diff --git a/packages/message_server/src/routes/index.js b/packages/message_server/src/routes/index.js new file mode 100644 index 00000000..96c728da --- /dev/null +++ b/packages/message_server/src/routes/index.js @@ -0,0 +1,3 @@ +export default [ + +] \ No newline at end of file