diff --git a/packages/music_spaces_server/package.json b/packages/music_spaces_server/package.json new file mode 100644 index 00000000..979a3cf7 --- /dev/null +++ b/packages/music_spaces_server/package.json @@ -0,0 +1,31 @@ +{ + "name": "@comty/music_spaces_server", + "version": "0.41.10", + "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", + "spotify-ws": "^0.1.1" + }, + "devDependencies": { + "cross-env": "^7.0.3", + "nodemon": "^2.0.15" + } +} diff --git a/packages/music_spaces_server/src/api.js b/packages/music_spaces_server/src/api.js new file mode 100644 index 00000000..71e338e6 --- /dev/null +++ b/packages/music_spaces_server/src/api.js @@ -0,0 +1,250 @@ +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:3010", + headers: { + "server_token": `${process.env.MAIN_SERVER_ID}:${process.env.MAIN_SERVER_TOKEN}`, + } +}) + +class SpotifyRoom { + constructor(options = {}) { + this.options = { + ...options, + } + + this.owner_user_id = options.owner_user_id + + this.roomId = options.roomId + } + + owner = null + + listeners = [] + + appendListener = (listener) => { + + } + + initialize = async () => { + + } +} + + +class RealtimeRoomEventServer { + constructor(server, options = {}) { + this.io = socketio(server, { + cors: { + origin: "*", + methods: ["GET", "POST"], + credentials: true, + } + }) + + this.limitations = { + ...options.limitations, + } + } + + connectionPool = [] + + roomEventsHandlers = { + "owner:update": (socket, payload) => { + + } + } + + initializeSocketIO = () => { + this.io.use(async (socket, next) => { + try { + const token = socket.handshake.auth.token + + if (!token) { + return next(new Error(`auth:token_missing`)) + } + + const session = await mainAPI.post("/session/validate", { + session: token + }) + .then((res) => { + return res.data + }) + .catch((err) => { + console.log(err.response.data) + return false + }) + + if (!session || !session?.valid) { + return next(new Error(`auth:token_invalid`)) + } + + const userData = await mainAPI.get(`/user/${session.user_id}/data`) + .then((res) => { + return res.data + }) + .catch((err) => { + console.log(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() + } catch (error) { + next(new Error(`auth:authentification_failed`)) + } + }) + + 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, type } = payload + + if (!room) { + return cb(new Error(`room:invalid`)) + } + + 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)) + }) + + // start spotify ws connection + + const roomConnections = this.connectionPool.filter((client) => client.room === room).length + + return 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.roomServer = new RealtimeRoomEventServer(this.httpServer) + + this.options = { + listenPort: process.env.PORT || 3030, + ...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.roomServer.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/music_spaces_server/src/index.js b/packages/music_spaces_server/src/index.js new file mode 100644 index 00000000..8363cf98 --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/middlewares/errorHandler/index.js b/packages/music_spaces_server/src/middlewares/errorHandler/index.js new file mode 100755 index 00000000..d8e53df2 --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/middlewares/hasPermissions/index.js b/packages/music_spaces_server/src/middlewares/hasPermissions/index.js new file mode 100755 index 00000000..2cc254a8 --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/middlewares/index.js b/packages/music_spaces_server/src/middlewares/index.js new file mode 100755 index 00000000..d22af7db --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/middlewares/onlyAdmin/index.js b/packages/music_spaces_server/src/middlewares/onlyAdmin/index.js new file mode 100755 index 00000000..730faba8 --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/middlewares/permissions/index.js b/packages/music_spaces_server/src/middlewares/permissions/index.js new file mode 100755 index 00000000..bff9e765 --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/middlewares/roles/index.js b/packages/music_spaces_server/src/middlewares/roles/index.js new file mode 100755 index 00000000..16c9e3c3 --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/middlewares/withAuthentication/index.js b/packages/music_spaces_server/src/middlewares/withAuthentication/index.js new file mode 100755 index 00000000..b3dcf481 --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/middlewares/withOptionalAuthentication/index.js b/packages/music_spaces_server/src/middlewares/withOptionalAuthentication/index.js new file mode 100755 index 00000000..4a03cd05 --- /dev/null +++ b/packages/music_spaces_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/music_spaces_server/src/routes/index.js b/packages/music_spaces_server/src/routes/index.js new file mode 100755 index 00000000..96c728da --- /dev/null +++ b/packages/music_spaces_server/src/routes/index.js @@ -0,0 +1,3 @@ +export default [ + +] \ No newline at end of file