diff --git a/package-lock.json b/package-lock.json index 3e3c2aab..fb9a33ce 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index d443044e..5aafa609 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,6 @@ "image-size": "^2.0.2", "json-bigint": "^1.0.0", "jsonwebtoken": "^9.0.2", - "lambert-server": "^1.2.12", "missing-native-js-functions": "^2.0.0", "module-alias": "^2.2.3", "morgan": "^1.10.1", @@ -119,7 +118,8 @@ "@spacebar/cdn": "dist/cdn", "@spacebar/gateway": "dist/gateway", "@spacebar/util": "dist/util", - "@spacebar/webrtc": "dist/webrtc" + "@spacebar/webrtc": "dist/webrtc", + "lambert-server": "dist/util/util/lambert-server" }, "optionalDependencies": { "@sendgrid/mail": "^8.1.6", diff --git a/src/util/util/lambert-server/HTTPError.ts b/src/util/util/lambert-server/HTTPError.ts new file mode 100644 index 00000000..70ba92a8 --- /dev/null +++ b/src/util/util/lambert-server/HTTPError.ts @@ -0,0 +1,5 @@ +export class HTTPError extends Error { + constructor(message: string, public code: number = 400) { + super(message); + } +} diff --git a/src/util/util/lambert-server/Server.ts b/src/util/util/lambert-server/Server.ts new file mode 100644 index 00000000..2325ef49 --- /dev/null +++ b/src/util/util/lambert-server/Server.ts @@ -0,0 +1,174 @@ +import express, { Application, NextFunction, Request, Response, Router } from "express"; +import { traverseDirectory } from "./Utils"; +import { Server as HTTPServer } from "http"; +import { HTTPError } from "./HTTPError"; +import "express-async-errors"; +import "missing-native-js-functions"; +import bodyParser from "body-parser"; +// import helmet from "helmet"; +import http from "http"; +import chalk from "chalk"; + +declare global { + namespace Express { + interface Request { + server: Server; + } + } +} + +export type ServerOptions = { + port: number; + host: string; + production: boolean; + serverInitLogging: boolean; + errorHandler?: { (err: Error, req: Request, res: Response, next: NextFunction): any }; + jsonBody: boolean; + server: http.Server; + app: Application; +}; + +// Overwrite default options for Router with default value true for mergeParams +const oldRouter = express.Router; +express.Router = function (options?: express.RouterOptions | undefined): Router { + if (!options) options = {}; + if (options.mergeParams == null) options.mergeParams = true; + + return oldRouter(options); +}; + +export class Server { + public app: Application; + public http: HTTPServer; + public options: ServerOptions; + public routes: Router[]; + + constructor(opts?: Partial) { + if (!opts) opts = {}; + if (!opts.port) opts.port = 8080; + if (!opts.host) opts.host = "0.0.0.0"; + if (opts.production == null) opts.production = false; + if (opts.serverInitLogging == null) opts.serverInitLogging = true; + if (opts.errorHandler == null) opts.errorHandler = this.errorHandler; + if (opts.jsonBody == null) opts.jsonBody = true; + if (opts.server) this.http = opts.server; + + this.options = opts; + + if (opts.app) this.app = opts.app; + else this.app = express(); + } + + // protected secureExpress() { + // this.app.use(helmet.contentSecurityPolicy()); + // this.app.use(helmet.expectCt); + // this.app.use(helmet.originAgentCluster()); + // this.app.use(helmet.referrerPolicy({ policy: "same-origin" })); + // this.app.use(helmet.hidePoweredBy()); + // this.app.use(helmet.noSniff()); + // this.app.use(helmet.dnsPrefetchControl({ allow: true })); + // this.app.use(helmet.ieNoOpen()); + // this.app.use(helmet.frameguard({ action: "deny" })); + // this.app.use(helmet.permittedCrossDomainPolicies({ permittedPolicies: "none" })); + // } + + public errorHandler = (error: Error, req: Request, res: Response, next: NextFunction) => { + try { + let code; + let message = error?.toString(); + + if (error instanceof HTTPError && error.code) code = error.code || 400; + else { + console.error(error); + if (this.options.production) { + message = "Internal Server Error"; + } + code = 500; + } + + res.status(code).json({ success: false, code: code, error: true, message }); + } catch (e) { + console.error(e); + return res.status(500).json({ success: false, code: 500, error: true, message: "Internal Server Error" }); + } + }; + + async start() { + const server = this.http || this.app; + if (!server.listening) { + await new Promise((res) => { + this.http = server.listen(this.options.port, () => res()); + }); + if(this.options.serverInitLogging) this.log("info", `[Server] started on ${this.options.host}:${this.options.port}`); + } + } + + async registerRoutes(root: string) { + this.app.use((req, res, next) => { + req.server = this; + next(); + }); + if (this.options.jsonBody) this.app.use(bodyParser.json()); + const result = await traverseDirectory({ dirname: root, recursive: true }, this.registerRoute.bind(this, root)); + if (this.options.errorHandler) this.app.use(this.options.errorHandler); + // if (this.options.production) this.secureExpress(); + return result; + } + + log(l: "info" | "error" | "warn" | "verbose", ...args: any[]) { + // @ts-ignore + if (!console[l]) l = "verbose"; + + const level = l === "verbose" ? "log" : l; + + var color: "red" | "yellow" | "blue" | "reset"; + + switch (level) { + case "error": + color = "red"; + break; + case "warn": + color = "yellow"; + break; + case "info": + color = "blue"; + case "log": + default: + color = "reset"; + } + + if (this.options.production && l === "verbose") return; + + console[level](chalk[color](`[${new Date().toTimeString().split(" ")[0]}]`), ...args); + } + + registerRoute(root: string, file: string): any { + if (root.endsWith("/") || root.endsWith("\\")) root = root.slice(0, -1); // removes slash at the end of the root dir + let path = file.replace(root, ""); // remove root from path and + path = path.split(".").slice(0, -1).join("."); // trancate .js/.ts file extension of path + path = path.replaceAll("#", ":").replaceAll("!", "?").replaceAll("\\", "/"); + if (path.endsWith("/index")) path = path.slice(0, -6); // delete index from path + if (!path.length) path = "/"; // first root index.js file must have a / path + + try { + var router = require(file); + if (router.router) router = router.router; + if (router.default) router = router.default; + if (!router || router?.prototype?.constructor?.name !== "router") + throw `File doesn't export any default router`; + + if (this.options.errorHandler) router.use(this.options.errorHandler); + this.app.use(path, router); + + if(this.options.serverInitLogging) this.log("verbose", `[Server] Route ${path} registered`); + + return router; + } catch (error) { + console.error(new Error(`[Server] Failed to register route ${path}: ${error}`)); + } + } + + stop() { + return new Promise((res) => this.http.close(() => res())); + } +} diff --git a/src/util/util/lambert-server/Utils.ts b/src/util/util/lambert-server/Utils.ts new file mode 100644 index 00000000..2e30c24d --- /dev/null +++ b/src/util/util/lambert-server/Utils.ts @@ -0,0 +1,40 @@ +import fs from "fs"; +import "missing-native-js-functions"; + +export interface traverseDirectoryOptions { + dirname: string; + filter?: RegExp; + excludeDirs?: RegExp; + recursive?: boolean; +} + +const DEFAULT_EXCLUDE_DIR = /^\./; +const DEFAULT_FILTER = /^([^\.].*)(?( + options: traverseDirectoryOptions, + action: (path: string) => T +): Promise { + if (!options.filter) options.filter = DEFAULT_FILTER; + if (!options.excludeDirs) options.excludeDirs = DEFAULT_EXCLUDE_DIR; + + const routes = fs.readdirSync(options.dirname); + const promises = []>routes + .sort((a, b) => (a.startsWith("#") ? 1 : -1)) // load #parameter routes last + .map(async (file) => { + const path = options.dirname + file; + const stat = fs.lstatSync(path); + if (path.match(options.excludeDirs)) return; + + if (path.match(options.filter) && stat.isFile()) { + return action(path); + } else if (options.recursive && stat.isDirectory()) { + return traverseDirectory({ ...options, dirname: path + "/" }, action); + } + }); + const result = await Promise.all(promises); + + const t = <(T | undefined)[]>result.flat(); + + return t.filter((x) => x != undefined); +} diff --git a/src/util/util/lambert-server/check.ts b/src/util/util/lambert-server/check.ts new file mode 100644 index 00000000..c025bcbf --- /dev/null +++ b/src/util/util/lambert-server/check.ts @@ -0,0 +1,127 @@ +import "missing-native-js-functions"; +import { NextFunction, Request, Response } from "express"; +import { HTTPError } from "."; + +const OPTIONAL_PREFIX = "$"; +const EMAIL_REGEX = + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + +export function check(schema: any) { + return (req: Request, res: Response, next: NextFunction) => { + try { + const result = instanceOf(schema, req.body, { path: "body" }); + if (result === true) return next(); + throw result; + } catch (error) { + next(new HTTPError((error as any).toString(), 400)); + } + }; +} + +export class Tuple { + public types: any[]; + constructor(...types: any[]) { + this.types = types; + } +} + +export class Email { + constructor(public email: string) {} + check() { + return !!this.email.match(EMAIL_REGEX); + } +} + +export function instanceOf( + type: any, + value: any, + { path = "", optional = false }: { path?: string; optional?: boolean } = {} +): Boolean { + if (!type) return true; // no type was specified + + if (value == null) { + if (optional) return true; + throw `${path} is required`; + } + + switch (type) { + case String: + if (typeof value === "string") return true; + throw `${path} must be a string`; + case Number: + value = Number(value); + if (typeof value === "number" && !isNaN(value)) return true; + throw `${path} must be a number`; + case BigInt: + try { + value = BigInt(value); + if (typeof value === "bigint") return true; + } catch (error) {} + throw `${path} must be a bigint`; + case Boolean: + if (value == "true") value = true; + if (value == "false") value = false; + if (typeof value === "boolean") return true; + throw `${path} must be a boolean`; + case Object: + if (typeof value === "object" && value !== null) return true; + throw `${path} must be a object`; + } + + if (typeof type === "object") { + if (Array.isArray(type)) { + if (!Array.isArray(value)) throw `${path} must be an array`; + if (!type.length) return true; // type array didn't specify any type + + return value.every((val, i) => instanceOf(type[0], val, { path: `${path}[${i}]`, optional })); + } + if (type?.constructor?.name != "Object") { + if (type instanceof Tuple) { + if ( + (type).types.some((x) => { + try { + return instanceOf(x, value, { path, optional }); + } catch (error) { + return false; + } + }) + ) { + return true; + } + throw `${path} must be one of ${type.types}`; + } + if (type instanceof Email) { + if ((type).check()) return true; + throw `${path} is not a valid E-Mail`; + } + if (value instanceof type) return true; + throw `${path} must be an instance of ${type}`; + } + if (typeof value !== "object") throw `${path} must be a object`; + + const diff = Object.keys(value).missing( + Object.keys(type).map((x) => (x.startsWith(OPTIONAL_PREFIX) ? x.slice(OPTIONAL_PREFIX.length) : x)) + ); + + if (diff.length) throw `Unkown key ${diff}`; + + return Object.keys(type).every((key) => { + let newKey = key; + const OPTIONAL = key.startsWith(OPTIONAL_PREFIX); + if (OPTIONAL) newKey = newKey.slice(OPTIONAL_PREFIX.length); + + return instanceOf(type[key], value[newKey], { + path: `${path}.${newKey}`, + optional: OPTIONAL, + }); + }); + } else if (typeof type === "number" || typeof type === "string" || typeof type === "boolean") { + if (value === type) return true; + throw `${path} must be ${value}`; + } else if (typeof type === "bigint") { + if (BigInt(value) === type) return true; + throw `${path} must be ${value}`; + } + + return type == value; +} diff --git a/src/util/util/lambert-server/index.ts b/src/util/util/lambert-server/index.ts new file mode 100644 index 00000000..6575a7d3 --- /dev/null +++ b/src/util/util/lambert-server/index.ts @@ -0,0 +1,4 @@ +export * from "./check"; +export * from "./Server"; +export * from "./Utils"; +export * from "./HTTPError"; diff --git a/tsconfig.json b/tsconfig.json index f5cfbe04..3ca6bc72 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -37,7 +37,8 @@ "@spacebar/gateway*": ["./gateway"], "@spacebar/cdn*": ["./cdn"], "@spacebar/util*": ["./util"], - "@spacebar/webrtc*": ["./webrtc"] + "@spacebar/webrtc*": ["./webrtc"], + "lambert-server*": ["./util/util/lambert-server"] } /* Specify a set of entries that re-map imports to additional lookup locations. */, // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */