From e4065c5767f37d26898ac9fcfb03d4229e5e56ca Mon Sep 17 00:00:00 2001 From: KagChi <59391215+KagChi@users.noreply.github.com> Date: Thu, 22 Dec 2022 17:58:10 +0700 Subject: [PATCH] feat: initial v2 rewrite (#96) --- .env.example | 1 + README.md | 7 +- api/index.ts | 210 ++++++++++++++++++++++++++++++++++++++-- api/types.ts | 6 ++ package.json | 17 ++-- src/app.controller.ts | 148 ---------------------------- src/app.module.ts | 12 --- src/app.node.service.ts | 19 ---- tsconfig.json | 4 +- 9 files changed, 222 insertions(+), 202 deletions(-) create mode 100644 api/types.ts delete mode 100644 src/app.controller.ts delete mode 100644 src/app.module.ts delete mode 100644 src/app.node.service.ts diff --git a/.env.example b/.env.example index 53e5d69..5a11b11 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,7 @@ LAVALINKS = '[ "name": "node-1" } ]' +DISABLED_ROUTES = ["/plugins"] AUTHORIZATION = youshallnotpass TIMEOUT_SECONDS = 5 WEBHOOK_URL = https://example.com/path diff --git a/README.md b/README.md index 48c871a..d5e5182 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,11 @@ # Features - Written in TypeScript - Multiple Lavalink nodes support -- Configure-able response timeout +- Configure-able response timeout, for retries (only for `/loadtracks`, `/decodetracks`, `/decodetrack` routes) - Vercel Serverless support - Use preferred custom node using `x-node-name` headers - Usage analytics, such as tracks info, requester (if user append x-requester-id). everything were sent to client webhook (if set). - -# Not implemented -- Lavalink RoutePlanner Endpoint -- Lavalink Plugins Endpoint +- No hardcoded route, note that basic `/loadtracks`, `/decodetracks`, `/decodetrack` routes are hardcoded for caching stuff # Usage - You may uses this for scaling LavaLink track loading not for proxying everything via this module \ No newline at end of file diff --git a/api/index.ts b/api/index.ts index 7adbdc1..2ccdbb3 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,10 +1,206 @@ -import { NestFactory } from "@nestjs/core"; -import { AppModule } from "../src/app.module"; +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +/* eslint-disable no-nested-ternary */ +import "reflect-metadata"; +import "dotenv/config"; -const NezlyApp = (async () => { - const app = await NestFactory.create(AppModule); +import Fastify from "fastify"; +import PinoPretty from "pino-pretty"; +import Pino from "pino"; +import { NodeOptions } from "./types"; +import { cast } from "@sapphire/utilities"; +import { REST } from "@kirishima/rest"; +import { fetch } from "undici"; +import { BodyInit } from "undici/types/fetch"; +import { Result } from "@sapphire/result"; +import { Time } from "@sapphire/time-utilities"; - await app.listen(parseInt(process.env.PORT ?? String(3000))); -})(); +const fastify = Fastify({ + logger: Pino({ + name: "nezu", + timestamp: true, + level: process.env.NODE_ENV === "production" ? "info" : "trace", + formatters: { + bindings: () => ({ + pid: "Nezly" + }) + } + }, PinoPretty()) +}); -export default NezlyApp; +function getLavalinkNode(nodeName?: string, excludeNode?: string): REST { + const nodes: NodeOptions[] = JSON.parse(cast(process.env.LAVALINKS ?? [])); + const firstNode = nodes + .filter(node => (excludeNode ? node.name !== excludeNode : nodeName ? node.name === nodeName : true))[ + nodeName ? 0 : Math.floor(Math.random() * (excludeNode ? nodes.length - 1 : nodes.length)) + ]; + return new REST(`${firstNode.secure ? "https" : "http"}://${firstNode.host}`) + .setAuthorization(firstNode.auth); +} + +fastify.get("/", () => ({ message: "LavaLink REST Proxy API" })); + +for (const route of cast(JSON.parse(process.env.DISABLED_ROUTES ?? "[]"))) { + fastify.route({ + method: ["GET", "POST", "PATCH"], + url: route, + handler: async (request, reply) => reply.status(404).send({ timestamp: new Date().toISOString(), status: 404, error: "Not Found", message: "Not Found", path: request.url }) + }); +} + +fastify.get("/loadtracks", { + schema: { + querystring: { + identifier: { type: "string" } + } + }, + preHandler: async (request, reply, done) => { + if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized")); + } +}, async (request, reply) => { + const node = getLavalinkNode( + Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"] + ); + const { identifier } = request.query as { identifier: string }; + + const source = identifier.split(":")[0]; + const query = identifier.split(":")[1]; + + const fetchTracks = async () => { + if (reply.sent) return; + const result = await node.loadTracks(source ? { source, query } : identifier); + if (process.env.WEBHOOK_URL) { + void Result.fromAsync(async () => { + await fetch(process.env.WEBHOOK_URL!, { + headers: { + Authorization: process.env.WEBHOOK_AUTHORIZATION! + }, + method: "POST", + body: JSON.stringify({ + ...result, + type: "LOAD_TRACKS", + user: request.headers["x-requester-id"] ?? null + }) + }); + }); + } + return reply.send(result); + }; + + const timeout = setTimeout(() => Result.fromAsync(() => fetchTracks()), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); + return fetchTracks().then(() => clearTimeout(timeout)); +}); + +fastify.get("/decodetrack", { + schema: { + querystring: { + track: { type: "string" } + } + }, + preHandler: async (request, reply, done) => { + if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized")); + } +}, async (request, reply) => { + const node = getLavalinkNode( + Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"] + ); + const { track } = request.query as { track: string }; + + const fetchTracks = async () => { + if (reply.sent) return; + const result = await node.decodeTracks(track); + if (process.env.WEBHOOK_URL) { + void Result.fromAsync(async () => { + await fetch(process.env.WEBHOOK_URL!, { + method: "POST", + headers: { + Authorization: process.env.WEBHOOK_AUTHORIZATION! + }, + body: JSON.stringify({ + type: "DECODE_TRACKS", + user: request.headers["x-requester-id"] ?? null, + tracks: result + }) + }); + }); + } + return reply.send(result[0].info); + }; + + const timeout = setTimeout(() => Result.fromAsync(() => fetchTracks()), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); + return fetchTracks().then(() => clearTimeout(timeout)); +}); + +fastify.post("/decodetracks", { + preHandler: async (request, reply, done) => { + if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized")); + } +}, async (request, reply) => { + const node = getLavalinkNode( + Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"] + ); + const { tracks } = request.body as { tracks: string }; + const fetchTracks = async () => { + if (reply.sent) return; + const result = await node.decodeTracks(tracks); + if (process.env.WEBHOOK_URL) { + void Result.fromAsync(async () => { + await fetch(process.env.WEBHOOK_URL!, { + method: "POST", + headers: { + Authorization: process.env.WEBHOOK_AUTHORIZATION! + }, + body: JSON.stringify({ + type: "DECODE_TRACKS", + user: request.headers["x-requester-id"] ?? null, + tracks: result + }) + }); + }); + } + return reply.send(result.map(x => x.info)); + }; + + const timeout = setTimeout(() => Result.fromAsync(() => fetchTracks()), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); + return fetchTracks().then(() => clearTimeout(timeout)); +}); + +fastify.get("*", { + preHandler: async (request, reply, done) => { + if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized")); + } +}, async (request, reply) => { + const node = getLavalinkNode( + Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"] + ); + const fetchResult = await fetch(`${node.url}${request.url}`, { method: "GET", headers: { ...node.headers } }); + if (fetchResult.headers.get("content-type")?.startsWith("application/json")) return reply.status(fetchResult.status).send(await fetchResult.json()); + return reply.status(fetchResult.status).send(await fetchResult.text()); +}); + +fastify.post("*", { + preHandler: async (request, reply, done) => { + if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized")); + } +}, async (request, reply) => { + const node = getLavalinkNode( + Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"] + ); + const fetchResult = await fetch(`${node.url}${request.url}`, { method: "POST", body: request.body as BodyInit, headers: { ...node.headers } }); + if (fetchResult.headers.get("content-type")?.startsWith("application/json")) return reply.status(fetchResult.status).send(await fetchResult.json()); + return reply.status(fetchResult.status).send(await fetchResult.text()); +}); + +fastify.patch("*", { + preHandler: async (request, reply, done) => { + if (process.env.AUTHORIZATION && request.headers.authorization !== process.env.AUTHORIZATION) return done(new Error("Unauthorized")); + } +}, async (request, reply) => { + const node = getLavalinkNode( + Array.isArray(request.headers["x-node-name"]) ? request.headers["x-node-name"][0] : request.headers["x-node-name"] + ); + const fetchResult = await fetch(`${node.url}${request.url}`, { method: "patch", body: request.body as BodyInit, headers: { ...node.headers } }); + if (fetchResult.headers.get("content-type")?.startsWith("application/json")) return reply.status(fetchResult.status).send(await fetchResult.json()); + return reply.status(fetchResult.status).send(await fetchResult.text()); +}); + +void fastify.listen({ host: "0.0.0.0", port: Number(process.env.PORT ?? 3000) }); diff --git a/api/types.ts b/api/types.ts new file mode 100644 index 0000000..5fa2d05 --- /dev/null +++ b/api/types.ts @@ -0,0 +1,6 @@ +export interface NodeOptions { + name: string; + host: string; + auth: string; + secure?: boolean; +} diff --git a/package.json b/package.json index c2b6c56..23e5747 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { "name": "nezly", - "version": "1.0.0", + "version": "2.0.0", "description": "A REST Proxy container for the Lavalink REST API.", - "main": "dist/api/index.js", + "main": "dist/index.js", "scripts": { "compile": "rimraf dist && tsc --outDir dist", - "lint": "eslint **/*.ts", - "lint:fix": "eslint **/*.ts --fix" + "lint": "eslint api/**/*.ts", + "lint:fix": "eslint api/**/*.ts --fix", + "start:dev": "npm run compile && node dist" }, "author": "KagChi", "license": "GPL-3.0", @@ -44,17 +45,15 @@ }, "dependencies": { "@kirishima/rest": "^0.2.5", - "@nestjs/common": "^9.2.1", - "@nestjs/core": "^9.2.1", - "@nestjs/platform-express": "^9.2.1", "@sapphire/result": "^2.6.0", "@sapphire/time-utilities": "^1.7.8", "@sapphire/utilities": "^3.11.0", "dotenv": "^16.0.3", - "express": "^4.18.2", + "fastify": "^4.10.2", "lavalink-api-types": "^1.1.5", + "pino": "^8.8.0", + "pino-pretty": "^9.1.1", "reflect-metadata": "^0.1.13", - "rxjs": "^7.6.0", "undici": "^5.14.0" } } diff --git a/src/app.controller.ts b/src/app.controller.ts deleted file mode 100644 index d916208..0000000 --- a/src/app.controller.ts +++ /dev/null @@ -1,148 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -import { Body, Controller, Get, Post, Query, Req, Res } from "@nestjs/common"; -import { Response, Request } from "express"; -import { LoadTypeEnum } from "lavalink-api-types"; -import { AppNodeService } from "./app.node.service"; -import { REST } from "@kirishima/rest"; -import { Result } from "@sapphire/result"; -import { Time } from "@sapphire/time-utilities"; -import { fetch } from "undici"; - -@Controller() - -export class AppController { - public constructor(private readonly appNodeService: AppNodeService) {} - - @Get() - public getIndex(@Res() res: Response): Response { - return res.json({ message: "LavaLink REST Proxy API" }); - } - - @Get("/loadtracks") - public async getLoadTracks( - @Res() res: Response, - @Req() req: Request, - @Query("identifier") identifier?: string, - excludeNode?: string, - resolveAttempt?: number - ): Promise { - try { - if (process.env.AUTHORIZATION && req.headers.authorization !== process.env.AUTHORIZATION) return res.sendStatus(401); - if (!identifier || (resolveAttempt && resolveAttempt > 3)) return res.json({ playlistInfo: {}, loadType: LoadTypeEnum.NO_MATCHES, tracks: [] }); - const node = this.appNodeService.getLavalinkNode(req.headers["x-node-name"] as string, excludeNode); - const nodeRest = new REST(node.secure ? `https://${node.host}` : `http://${node.host}`) - .setAuthorization(node.auth); - - const source = identifier.split(":")[0]; - const query = identifier.split(":")[1]; - - const timeout = setTimeout(() => Result.fromAsync(() => this.getLoadTracks(res, req, identifier, node.name)), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); - const result = await nodeRest.loadTracks(source ? { source, query } : identifier); - clearTimeout(timeout); - - if (!result.tracks.length) return await this.getLoadTracks(res, req, identifier, node.name, (resolveAttempt ?? 0) + 1); - if (process.env.WEBHOOK_URL) { - /** Not resolving async because of the result maybe timeout because of the posting. */ - void Result.fromAsync(async () => { - await fetch(process.env.WEBHOOK_URL!, { - headers: { - Authorization: process.env.WEBHOOK_AUTHORIZATION! - }, - method: "POST", - body: JSON.stringify({ - type: "LOAD_TRACKS", - user: req.headers["x-requester-id"] ?? null, - tracks: result.tracks, - loadType: result.loadType, - playlistInfo: result.playlistInfo - }) - }); - }); - } - - return res.json(result); - } catch (e) { - return res.status(500).json({ status: 500, message: e.message }); - } - } - - @Get("/decodetrack") - public async getDecodeTrack( - @Res() res: Response, - @Req() req: Request, - @Query("track") track: string, - excludeNode?: string - ): Promise { - try { - if (process.env.AUTHORIZATION && req.headers.authorization !== process.env.AUTHORIZATION) return res.sendStatus(401); - const node = this.appNodeService.getLavalinkNode(req.headers["x-node-name"] as string, excludeNode); - const nodeRest = new REST(node.secure ? `https://${node.host}` : `http://${node.host}`) - .setAuthorization(node.auth); - - const timeout = setTimeout(() => Result.fromAsync(() => this.getDecodeTrack(res, req, track, node.name)), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); - const result = await nodeRest.decodeTracks([track]); - clearTimeout(timeout); - - if (process.env.WEBHOOK_URL) { - /** Not resolving async because of the result maybe timeout because of the posting. */ - void Result.fromAsync(async () => { - await fetch(process.env.WEBHOOK_URL!, { - headers: { - Authorization: process.env.WEBHOOK_AUTHORIZATION! - }, - method: "POST", - body: JSON.stringify({ - type: "DECODE_TRACKS", - user: req.headers["x-requester-id"] ?? null, - tracks: result - }) - }); - }); - } - - return res.json(result[0].info); - } catch (e) { - return res.status(500).json({ status: 500, message: e.message }); - } - } - - @Post("/decodetracks") - public async postDecodeTracks( - @Res() res: Response, - @Req() req: Request, - @Body() tracks: string, - excludeNode?: string - ): Promise { - try { - if (process.env.AUTHORIZATION && req.headers.authorization !== process.env.AUTHORIZATION) return res.sendStatus(401); - const node = this.appNodeService.getLavalinkNode(req.headers["x-node-name"] as string, excludeNode); - const nodeRest = new REST(node.secure ? `https://${node.host}` : `http://${node.host}`) - .setAuthorization(node.auth); - - const timeout = setTimeout(() => Result.fromAsync(() => this.postDecodeTracks(res, req, tracks, node.name)), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); - const result = await nodeRest.decodeTracks(tracks); - clearTimeout(timeout); - - if (process.env.WEBHOOK_URL) { - /** Not resolving async because of the result maybe timeout because of the posting. */ - void Result.fromAsync(async () => { - await fetch(process.env.WEBHOOK_URL!, { - headers: { - Authorization: process.env.WEBHOOK_AUTHORIZATION! - }, - method: "POST", - body: JSON.stringify({ - type: "DECODE_TRACKS", - user: req.headers["x-requester-id"] ?? null, - tracks: result - }) - }); - }); - } - - return res.json(result.map(x => x.info)); - } catch (e) { - return res.status(500).json({ status: 500, message: e.message }); - } - } -} diff --git a/src/app.module.ts b/src/app.module.ts deleted file mode 100644 index 36deb27..0000000 --- a/src/app.module.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Module } from "@nestjs/common"; -import "dotenv/config"; -import { AppController } from "./app.controller"; -import { AppNodeService } from "./app.node.service"; - -@Module({ - controllers: [AppController], - providers: [AppNodeService], - imports: [] -}) - -export class AppModule {} diff --git a/src/app.node.service.ts b/src/app.node.service.ts deleted file mode 100644 index f43cec7..0000000 --- a/src/app.node.service.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* eslint-disable no-nested-ternary */ -/* eslint-disable max-len */ -import { Injectable } from "@nestjs/common"; -import { cast } from "@sapphire/utilities"; - -@Injectable() -export class AppNodeService { - public getLavalinkNode(nodeName?: string, excludeNode?: string): NodeOptions { - const nodes: NodeOptions[] = JSON.parse(cast(process.env.LAVALINKS ?? [])); - return nodes.filter(node => (excludeNode ? node.name !== excludeNode : nodeName ? node.name === nodeName : true))[nodeName ? 0 : Math.floor(Math.random() * (excludeNode ? nodes.length - 1 : nodes.length))]; - } -} - -interface NodeOptions { - name: string; - host: string; - auth: string; - secure?: boolean; -} diff --git a/tsconfig.json b/tsconfig.json index 898cc27..dc95732 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,6 @@ "incremental": true, "skipLibCheck": true }, - "include": ["./**/**.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "node_modules/**/**.d.ts"], + "include": ["api/**/**.ts", "api/**/**.json", "tsup.config.ts"], }