implement sync_server

This commit is contained in:
SrGooglo 2023-07-27 00:05:49 +00:00
parent 2d464a7fae
commit 13bdccd53d
28 changed files with 916 additions and 0 deletions

View File

@ -0,0 +1,20 @@
DB_HOSTNAME=""
DB_NAME=""
DB_USER=""
DB_PWD=""
S3_ENDPOINT=""
S3_REGION=""
S3_PORT=""
S3_USE_SSL=""
S3_BUCKET=""
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
REDIS_HOST=""
REDIS_PORT=""
REDIS_USERNAME=""
REDIS_PASSWORD=""
COMTY_ACCESS_KEY=""
COMTY_SECRET_KEY=""

2
packages/sync_server/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
/static
shared-classes

View File

@ -0,0 +1,7 @@
{
"workspaceId": "64519574a8a691b55e8b361d",
"defaultEnvironment": "dev",
"gitBranchToEnvironmentMapping": {
"master": "prod"
}
}

27
packages/sync_server/Dockerfile Executable file
View File

@ -0,0 +1,27 @@
FROM node:16-bullseye-slim
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt update
RUN apt install --no-install-recommends curl python yarn build-essential -y
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
# Copy comty.js to node_modules
WORKDIR /home/node/app
USER node
EXPOSE 3070
COPY package.json ./
COPY --chown=node:node . .
RUN chmod -R 777 /home/node/app
RUN export NODE_ENV=production
RUN yarn global add cross-env
RUN yarn install --production
RUN yarn build
CMD ["yarn", "run", "run:prod"]

View File

@ -0,0 +1,64 @@
{
"name": "@comty/sync_server",
"version": "0.54.4",
"main": "dist/index.js",
"scripts": {
"build": "corenode-cli build",
"dev": "cross-env NODE_ENV=development nodemon --ignore dist/ --exec corenode-node ./src/index.js",
"run:prod": "cross-env NODE_ENV=production node ./dist/index.js"
},
"sharedClasses": {
"FileUpload": "src/shared-classes",
"CacheService": "src/shared-classes",
"ComtyClient": "src/shared-classes",
"RedisClient": "src/shared-classes",
"StorageClient": "src/shared-classes",
"DbManager": "src/shared-classes",
"Errors": "src/shared-classes",
"DbModels": "src/shared-classes",
"SecureSyncEntry": "src/shared-classes",
"TidalAPI": "src/shared-classes"
},
"license": "MIT",
"dependencies": {
"7zip-min": "^1.4.4",
"@corenode/utils": "0.28.26",
"@foxify/events": "^2.1.0",
"@octokit/rest": "^19.0.7",
"axios": "^1.2.5",
"bcrypt": "^5.1.0",
"busboy": "^1.6.0",
"comty.js": "^0.54.0",
"connect-mongo": "^4.6.0",
"content-range": "^2.0.2",
"corenode": "0.28.26",
"dotenv": "^16.0.3",
"form-data": "^4.0.0",
"formidable": "^2.1.1",
"hyper-express": "^6.5.9",
"jsonwebtoken": "^9.0.0",
"linebridge": "0.15.12",
"live-directory": "^3.0.3",
"luxon": "^3.2.1",
"merge-files": "^0.1.2",
"mime-types": "^2.1.35",
"minio": "^7.0.32",
"moment": "^2.29.4",
"moment-timezone": "^0.5.40",
"mongoose": "^6.9.0",
"normalize-url": "^8.0.0",
"p-map": "^6.0.0",
"p-queue": "^7.3.4",
"redis": "^4.6.6",
"sharp": "^0.31.3",
"split-chunk-merge": "^1.0.0",
"sucrase": "^3.32.0",
"uglify-js": "^3.17.4"
},
"devDependencies": {
"chai": "^4.3.7",
"cross-env": "^7.0.3",
"mocha": "^10.2.0",
"nodemon": "^2.0.15"
}
}

View File

@ -0,0 +1,142 @@
import fs from "fs"
import path from "path"
import DbManager from "@shared-classes/DbManager"
import RedisClient from "@shared-classes/RedisClient"
import ComtyClient from "@shared-classes/ComtyClient"
import hyperexpress from "hyper-express"
import pkg from "../package.json"
export default class API {
static useMiddlewaresOrder = ["useLogger", "useCors", "useAuth"]
server = global.server = new hyperexpress.Server()
listenIp = process.env.HTTP_LISTEN_IP ?? "0.0.0.0"
listenPort = process.env.HTTP_LISTEN_PORT ?? 3070
internalRouter = new hyperexpress.Router()
db = new DbManager()
ssePools = global.ssePools = {}
comty = global.comty = ComtyClient({
useWs: false,
})
redis = global.redis = RedisClient()
async __registerControllers() {
let controllersPath = fs.readdirSync(path.resolve(__dirname, "controllers"))
for await (const controllerPath of controllersPath) {
const controller = require(path.resolve(__dirname, "controllers", controllerPath)).default
if (!controller) {
console.error(`Controller ${controllerPath} not found.`)
continue
}
const controllerRouter = new hyperexpress.Router()
const controllerOutput = await controller(controllerRouter)
if (!controllerOutput) {
console.error(`Controller ${controllerPath} returning not valid handler.`)
continue
}
this.internalRouter.use(controllerOutput.path ?? "/", controllerOutput.router)
continue
}
}
async __registerInternalMiddlewares() {
let middlewaresPath = fs.readdirSync(path.resolve(__dirname, "useMiddlewares"))
// sort middlewares
middlewaresPath = middlewaresPath.sort((a, b) => {
const aIndex = this.constructor.useMiddlewaresOrder.indexOf(a.replace(".js", ""))
const bIndex = this.constructor.useMiddlewaresOrder.indexOf(b.replace(".js", ""))
if (aIndex === -1) {
return 1
}
if (bIndex === -1) {
return -1
}
return aIndex - bIndex
})
for await (const middlewarePath of middlewaresPath) {
const middleware = require(path.resolve(__dirname, "useMiddlewares", middlewarePath)).default
if (!middleware) {
console.error(`Middleware ${middlewarePath} not found.`)
continue
}
this.server.use(middleware)
}
}
__registerInternalRoutes() {
this.server.get("/", (req, res) => {
return res.status(200).json({
name: pkg.name,
version: pkg.version,
routes: this.__getRegisteredRoutes()
})
})
this.server.any("*", (req, res) => {
return res.status(404).json({
error: "Not found",
})
})
}
__getRegisteredRoutes() {
return this.internalRouter.routes.map((route) => {
return {
method: route.method,
path: route.pattern,
}
})
}
async initialize() {
const startHrTime = process.hrtime()
// initialize clients
await this.db.initialize()
await this.redis.initialize()
// register controllers & middlewares
await this.__registerInternalRoutes()
await this.__registerControllers()
await this.__registerInternalMiddlewares()
// use internal router
this.server.use(this.internalRouter)
// start server
await this.server.listen(this.listenPort, this.listenIp)
// calculate elapsed time
const elapsedHrTime = process.hrtime(startHrTime)
const elapsedTimeInMs = elapsedHrTime[0] * 1000 + elapsedHrTime[1] / 1e6
// log server started
console.log(`🚀 Server started ready on \n\t - http://${this.listenIp}:${this.listenPort} \n\t - Tooks ${elapsedTimeInMs}ms`)
}
}

View File

@ -0,0 +1,11 @@
import path from "path"
import createRoutesFromDirectory from "@utils/createRoutesFromDirectory"
export default async (router) => {
router = createRoutesFromDirectory("routes", path.resolve(__dirname, "routes"), router)
return {
path: "/",
router,
}
}

View File

@ -0,0 +1,28 @@
export default (req, res) => {
console.log(res.sse)
if (!res.sse) {
return res.json({
error: "Server event stream is not enabled.",
details: "SSE is required for this request."
})
}
res.sse.open()
res.sse.uid = req.params.sse_uid
global.ssePools[req.params.sse_uid] = res.sse
const pingInterval = setInterval(() => {
res.sse.send("ping")
}, 1000)
res.once("close", () => {
clearInterval(pingInterval)
delete global.ssePools[req.params.sse_uid]
})
res.status(200)
res.header('Content-Type', "text/event-stream")
res.header('Cache-Control', 'no-cache')
res.header('Connection', 'keep-alive')
}

View File

@ -0,0 +1,11 @@
import path from "path"
import createRoutesFromDirectory from "@utils/createRoutesFromDirectory"
export default async (router) => {
router = createRoutesFromDirectory("routes", path.resolve(__dirname, "routes"), router)
return {
path: "/services",
router,
}
}

View File

@ -0,0 +1,47 @@
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
import { AuthorizationError, InternalServerError } from "@shared-classes/Errors"
import TidalAPI from "@shared-classes/TidalAPI"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
const authProcess = await TidalAPI.getAuthUrl()
if (!authProcess) {
return new InternalServerError(req, res)
}
const checkInterval = setInterval(async () => {
const response = await TidalAPI.checkAuthStatus(authProcess.device_code).catch(() => {
return false
})
if (response) {
const userData = {
id: response.user.userId,
email: response.user.email,
username: response.user.username,
countryCode: response.user.countryCode,
}
// save to SecureSyncEntry
await SecureSyncEntry.set(req.session.user_id.toString(), "tidal_user", JSON.stringify(userData))
await SecureSyncEntry.set(req.session.user_id.toString(), "tidal_access_token", response.access_token)
await SecureSyncEntry.set(req.session.user_id.toString(), "tidal_refresh_token", response.refresh_token)
return clearInterval(checkInterval)
}
}, 3000)
setTimeout(() => {
clearInterval(checkInterval)
}, authProcess.expires_in * 1000)
return res.json({
auth_url: authProcess.url,
device_code: authProcess.deviceCode,
})
}

View File

@ -0,0 +1,22 @@
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
import { AuthorizationError, InternalServerError, NotFoundError } from "@shared-classes/Errors"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
let user = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_user")
try {
user = JSON.parse(user)
if (!user) {
return new NotFoundError(req, res)
}
return res.json(user)
} catch (error) {
return new InternalServerError(req, res)
}
}

View File

@ -0,0 +1,18 @@
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
import { AuthorizationError, NotFoundError } from "@shared-classes/Errors"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
let hasUser = await SecureSyncEntry.has(req.session.user_id.toString(), "tidal_user")
if (!hasUser) {
return new NotFoundError(req, res, "This account is not linked to a TIDAL account.")
}
return res.json({
active: hasUser
})
}

View File

@ -0,0 +1,36 @@
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
import { AuthorizationError, InternalServerError, NotFoundError } from "@shared-classes/Errors"
import TidalAPI from "@shared-classes/TidalAPI"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
try {
const access_token = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_access_token")
if (!access_token) {
return new AuthorizationError(req, res, "Its needed to link your TIDAL account to perform this action.")
}
let user_data = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_user")
user_data = JSON.parse(user_data)
const response = await TidalAPI.getTrackManifest({
track_id: req.params.track_id,
access_token: access_token,
country: user_data.countryCode
})
if (!response) {
return new NotFoundError(req, res, "Track is not available")
}
return res.json(response)
} catch (error) {
return new InternalServerError(req, res, error)
}
}

View File

@ -0,0 +1,27 @@
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
import { AuthorizationError, InternalServerError, NotFoundError } from "@shared-classes/Errors"
import TidalAPI from "@shared-classes/TidalAPI"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
const access_token = await SecureSyncEntry.get(req.session.user_id.toString(), "tidal_access_token")
if (!access_token) {
return new AuthorizationError(req, res, "Its needed to link your TIDAL account to perform this action.")
}
const response = await TidalAPI.getTrackPlaybackUrl({
track_id: req.params.track_id,
access_token: access_token
})
if (!response) {
return new NotFoundError(req, res, "Track is not available")
}
return res.json(response)
}

View File

@ -0,0 +1,11 @@
import TidalAPI from "@shared-classes/TidalAPI"
export default async (req, res) => {
const query = req.query
const response = await TidalAPI.search({
query: query.query
})
return res.json(response)
}

View File

@ -0,0 +1,16 @@
import SecureSyncEntry from "@shared-classes/SecureSyncEntry"
import { AuthorizationError, InternalServerError } from "@shared-classes/Errors"
export default async (req, res) => {
if (!req.session) {
return new AuthorizationError(req, res)
}
await SecureSyncEntry.delete(req.session.user_id.toString(), "tidal_user")
await SecureSyncEntry.delete(req.session.user_id.toString(), "tidal_access_token")
await SecureSyncEntry.delete(req.session.user_id.toString(), "tidal_refresh_token")
return res.json({
success: true
})
}

View File

@ -0,0 +1,130 @@
import { webcrypto as crypto } from "crypto"
import path from "path"
import { registerBaseAliases } from "linebridge/dist/server"
import infisical from "infisical-node"
require("dotenv").config()
global.isProduction = process.env.NODE_ENV === "production"
globalThis["__root"] = path.resolve(__dirname)
const customAliases = {
"root": globalThis["__root"],
"@shared-classes": path.resolve(__dirname, "_shared/classes"),
"@services": path.resolve(__dirname, "services"),
"@lib": path.resolve(__dirname, "lib"),
}
if (!global.isProduction) {
customAliases["comty.js"] = path.resolve(__dirname, "../../comty.js/src")
customAliases["@shared-classes"] = path.resolve(__dirname, "shared-classes")
}
if (process.env.USE_LINKED_SHARED) {
customAliases["@shared-classes"] = path.resolve(__dirname, "shared-classes")
}
registerBaseAliases(undefined, customAliases)
// 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")
}
global.nanoid = (t = 21) => crypto.getRandomValues(new Uint8Array(t)).reduce(((t, e) => t += (e &= 63) < 36 ? e.toString(36) : e < 62 ? (e - 26).toString(36).toUpperCase() : e > 62 ? "-" : "_"), "");
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 API from "./api"
async function main() {
if (process.env.INFISICAL_TOKEN) {
console.log(`🔑 Injecting env variables from INFISICAL...`)
const client = new infisical({
token: process.env.INFISICAL_TOKEN,
})
const secrets = await client.getAllSecrets()
// inject to process.env
secrets.forEach((secret) => {
process.env[secret.secretName] = secret.secretValue
})
}
const instance = new API()
await instance.initialize()
// kill on process exit
process.on("exit", () => {
if (typeof instance.server.close === "function") {
instance.server.close()
}
process.exit(0)
})
// kill on ctrl+c
process.on("SIGINT", () => {
if (typeof instance.server.close === "function") {
instance.server.close()
}
process.exit(0)
})
// kill on uncaught exceptions
process.on("uncaughtException", (error) => {
console.error(`🆘 [FATAL ERROR] >`, error)
if (typeof instance.server.close === "function") {
instance.server.close()
}
process.exit(1)
})
// kill on unhandled rejections
process.on("unhandledRejection", (error) => {
console.error(`🆘 [FATAL ERROR] >`, error)
if (typeof instance.server.close === "function") {
instance.server.close()
}
process.exit(1)
})
}
main().catch((error) => {
console.error(`🆘 [FATAL ERROR] >`, error)
})

View File

@ -0,0 +1,25 @@
export default async function (req, res, next) {
// extract authentification header
let auth = req.headers.authorization
if (!auth) {
return res.status(401).json({ error: "Unauthorized, missing token" })
}
auth = auth.replace("Bearer ", "")
// check if authentification is valid
const validation = await comty.rest.session.validateToken(auth).catch((error) => {
return {
valid: false,
}
})
if (!validation.valid) {
return res.status(401).json({ error: "Unauthorized" })
}
req.session = validation.session
return next()
}

View File

@ -0,0 +1,26 @@
export default async function (req, res, next) {
// extract authentification header
let auth = req.headers.authorization
if (!auth) {
return next()
}
auth = auth.replace("Bearer ", "")
// check if authentification is valid
const validation = await comty.rest.session.validateToken(auth).catch((error) => {
return {
valid: false,
}
})
if (!validation.valid) {
return next()
}
req.sessionToken = auth
req.session = validation.session
return next()
}

View File

@ -0,0 +1,55 @@
export default async (socket, next) => {
try {
const token = socket.handshake.auth.token
if (!token) {
return next(new Error(`auth:token_missing`))
}
const validation = await global.comty.rest.session.validateToken(token).catch((err) => {
console.error(`[${socket.id}] failed to validate session caused by server error`, err)
return {
valid: false,
error: err,
}
})
if (!validation.valid) {
if (validation.error) {
return next(new Error(`auth:server_error`))
}
return next(new Error(`auth:token_invalid`))
}
const session = validation.session
const userData = await global.comty.rest.user.data({
user_id: session.user_id,
}).catch((err) => {
console.error(`[${socket.id}] failed to get user data caused by server error`, err)
return null
})
if (!userData) {
return next(new Error(`auth:user_failed`))
}
try {
socket.userData = userData
socket.token = token
socket.session = session
}
catch (err) {
return next(new Error(`auth:decode_failed`))
}
next()
} catch (error) {
console.error(`[${socket.id}] failed to connect caused by server error`, error)
next(new Error(`auth:authentification_failed`))
}
}

View File

@ -0,0 +1,25 @@
export default async function (req, res, next) {
// extract authentification header
let auth = req.headers.authorization
if (!auth) {
return false
}
auth = auth.replace("Bearer ", "")
// check if authentification is valid
const validation = await comty.rest.session.validateToken(auth).catch((error) => {
return {
valid: false,
}
})
if (!validation.valid) {
return res.status(401).json({ error: "Unauthorized" })
}
req.session = validation.session
return true
}

View File

@ -0,0 +1,8 @@
import cors from "cors"
export default cors({
origin: "*",
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "CONNECT", "TRACE"],
preflightContinue: false,
optionsSuccessStatus: 204,
})

View File

@ -0,0 +1,14 @@
export default (req, res, next) => {
const startHrTime = process.hrtime()
res.on("finish", () => {
const elapsedHrTime = process.hrtime(startHrTime)
const elapsedTimeInMs = elapsedHrTime[0] * 1000 + elapsedHrTime[1] / 1e6
res._responseTimeMs = elapsedTimeInMs
console.log(`${req.method} ${res._status_code ?? res.statusCode ?? 200} ${req.url} ${elapsedTimeInMs}ms`)
})
next()
}

View File

@ -0,0 +1,12 @@
export default function composePayloadData(socket, data = {}) {
return {
user: {
user_id: socket.userData._id,
username: socket.userData.username,
fullName: socket.userData.fullName,
avatar: socket.userData.avatar,
},
command_issuer: data.command_issuer ?? socket.userData._id,
...data
}
}

View File

@ -0,0 +1,45 @@
import fs from "fs"
function createRoutesFromDirectory(startFrom, directoryPath, router) {
const files = fs.readdirSync(directoryPath)
files.forEach((file) => {
const filePath = `${directoryPath}/${file}`
const stat = fs.statSync(filePath)
if (stat.isDirectory()) {
createRoutesFromDirectory(startFrom, filePath, router)
} else if (file.endsWith(".js") || file.endsWith(".jsx") || file.endsWith(".ts") || file.endsWith(".tsx")) {
let splitedFilePath = filePath.split("/")
// slice the startFrom path
splitedFilePath = splitedFilePath.slice(splitedFilePath.indexOf(startFrom) + 1)
const method = splitedFilePath[0]
let route = splitedFilePath.slice(1, splitedFilePath.length).join("/")
route = route.replace(".jsx", "")
route = route.replace(".js", "")
route = route.replace(".ts", "")
route = route.replace(".tsx", "")
if (route === "index") {
route = "/"
} else {
route = `/${route}`
}
let handler = require(filePath)
handler = handler.default || handler
router[method](route, handler)
}
})
return router
}
export default createRoutesFromDirectory

View File

@ -0,0 +1,21 @@
export default function generateFnHandler(fn, socket) {
return async (...args) => {
if (typeof socket === "undefined") {
socket = arguments[0]
}
try {
fn(socket, ...args)
} catch (error) {
console.error(`[HANDLER_ERROR] ${error.message} >`, error.stack)
if (typeof socket.emit !== "function") {
return false
}
return socket.emit("error", {
message: error.message,
})
}
}
}

View File

@ -0,0 +1,46 @@
import fs from "node:fs"
import path from "node:path"
export default async (middlewares, middlewaresPath) => {
if (typeof middlewaresPath === "undefined") {
middlewaresPath = path.resolve(globalThis["__root"], "middlewares")
}
if (!fs.existsSync(middlewaresPath)) {
return undefined
}
if (typeof middlewares === "string") {
middlewares = [middlewares]
}
let fns = []
for await (const middlewareName of middlewares) {
const middlewarePath = path.resolve(middlewaresPath, middlewareName)
if (!fs.existsSync(middlewarePath)) {
console.error(`Middleware ${middlewareName} not found.`)
continue
}
const middleware = require(middlewarePath).default
if (!middleware) {
console.error(`Middleware ${middlewareName} not valid export.`)
continue
}
if (typeof middleware !== "function") {
console.error(`Middleware ${middlewareName} not valid function.`)
continue
}
fns.push(middleware)
}
return fns
}

View File

@ -0,0 +1,20 @@
export default (from, to) => {
const resolvedUrl = new URL(to, new URL(from, "resolve://"))
if (resolvedUrl.protocol === "resolve:") {
let { pathname, search, hash } = resolvedUrl
if (to.includes("@")) {
const fromUrl = new URL(from)
const toUrl = new URL(to, fromUrl.origin)
pathname = toUrl.pathname
search = toUrl.search
hash = toUrl.hash
}
return pathname + search + hash
}
return resolvedUrl.toString()
}