UNPKG

@nodecg/types

Version:

Dynamic broadcast graphics rendered in a browser

1,199 lines (1,177 loc) 166 kB
//#region rolldown:runtime var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __commonJS = (cb, mod) => function() { return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) { __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } } } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion let __nodecg_internal_util = require("@nodecg/internal-util"); let __effect_platform_node_NodeRuntime = require("@effect/platform-node/NodeRuntime"); __effect_platform_node_NodeRuntime = __toESM(__effect_platform_node_NodeRuntime); let effect = require("effect"); let node_fs = require("node:fs"); node_fs = __toESM(node_fs); let node_path = require("node:path"); node_path = __toESM(node_path); let yargs = require("yargs"); let cosmiconfig = require("cosmiconfig"); let klona_json = require("klona/json"); let zod = require("zod"); let node_os = require("node:os"); node_os = __toESM(node_os); let __sentry_node = require("@sentry/node"); __sentry_node = __toESM(__sentry_node); let express = require("express"); express = __toESM(express); let os = require("os"); os = __toESM(os); let node_crypto = require("node:crypto"); node_crypto = __toESM(node_crypto); let cookie_parser = require("cookie-parser"); cookie_parser = __toESM(cookie_parser); let express_session = require("express-session"); express_session = __toESM(express_session); let passport = require("passport"); passport = __toESM(passport); let passport_discord = require("passport-discord"); let passport_local = require("passport-local"); let passport_steam = require("passport-steam"); passport_steam = __toESM(passport_steam); let passport_twitch_helix = require("passport-twitch-helix"); let node_util = require("node:util"); let winston = require("winston"); winston = __toESM(winston); let __nodecg_database_adapter_sqlite_legacy = require("@nodecg/database-adapter-sqlite-legacy"); let body_parser = require("body-parser"); body_parser = __toESM(body_parser); let compression = require("compression"); compression = __toESM(compression); let express_transform_bare_module_specifiers = require("express-transform-bare-module-specifiers"); express_transform_bare_module_specifiers = __toESM(express_transform_bare_module_specifiers); let fast_memoize = require("fast-memoize"); fast_memoize = __toESM(fast_memoize); let lodash = require("lodash"); let socket_io = require("socket.io"); socket_io = __toESM(socket_io); let __nodecg_json_schema_lib = require("@nodecg/json-schema-lib"); __nodecg_json_schema_lib = __toESM(__nodecg_json_schema_lib); let hasha = require("hasha"); hasha = __toESM(hasha); let object_path = require("object-path"); object_path = __toESM(object_path); let events = require("events"); let __nodecg_json_schema_defaults = require("@nodecg/json-schema-defaults"); __nodecg_json_schema_defaults = __toESM(__nodecg_json_schema_defaults); let ajv = require("ajv"); ajv = __toESM(ajv); let ajv_dist_2019 = require("ajv/dist/2019"); ajv_dist_2019 = __toESM(ajv_dist_2019); let ajv_dist_2020 = require("ajv/dist/2020"); ajv_dist_2020 = __toESM(ajv_dist_2020); let ajv_draft_04 = require("ajv-draft-04"); ajv_draft_04 = __toESM(ajv_draft_04); let ajv_formats = require("ajv-formats"); ajv_formats = __toESM(ajv_formats); let fp_ts_Either = require("fp-ts/Either"); fp_ts_Either = __toESM(fp_ts_Either); let json_ptr = require("json-ptr"); let multer = require("multer"); multer = __toESM(multer); let chokidar = require("chokidar"); chokidar = __toESM(chokidar); let path = require("path"); path = __toESM(path); let semver = require("semver"); semver = __toESM(semver); let fp_ts_function = require("fp-ts/function"); let fp_ts_IOEither = require("fp-ts/IOEither"); fp_ts_IOEither = __toESM(fp_ts_IOEither); let fp_ts_Option = require("fp-ts/Option"); fp_ts_Option = __toESM(fp_ts_Option); let extend = require("extend"); extend = __toESM(extend); let fp_ts_boolean = require("fp-ts/boolean"); fp_ts_boolean = __toESM(fp_ts_boolean); let fp_ts_IO = require("fp-ts/IO"); fp_ts_IO = __toESM(fp_ts_IO); let fs = require("fs"); fs = __toESM(fs); let git_rev_sync = require("git-rev-sync"); git_rev_sync = __toESM(git_rev_sync); let cheerio = require("cheerio"); cheerio = __toESM(cheerio); let is_error = require("is-error"); is_error = __toESM(is_error); let serialize_error = require("serialize-error"); //#region src/server/_effect/boundary.ts var UnknownError = class extends effect.Data.TaggedError("UnknownError") { constructor(cause) { super(); this.cause = cause; this.message = this.cause instanceof Error ? this.cause.message : "An unknown error occurred"; } }; //#endregion //#region src/server/_effect/expect-error.ts const expectError = () => effect.Function.identity; //#endregion //#region src/server/_effect/log-level.ts const withLogLevelConfig = effect.Effect.fn(function* (effect$1) { const logLevel = yield* effect.Config.logLevel("LOG_LEVEL").pipe(effect.Config.withDefault(effect.LogLevel.Info)); return yield* effect.Logger.withMinimumLogLevel(effect$1, logLevel); }); //#endregion //#region src/server/_effect/span-logger.ts const withSpanProcessorLive = effect.Effect.fn(function* (effect$1) { if (!(yield* effect.Config.boolean("LOG_SPAN").pipe(effect.Config.withDefault(false)))) return yield* effect$1; const NodeSdk = yield* effect.Effect.promise(() => import("@effect/opentelemetry/NodeSdk")); const { SpanStatusCode } = yield* effect.Effect.promise(() => Promise.resolve().then(() => require("./esm-D8HCo8cr.js"))); const runtime = yield* effect.Effect.runtime(); const layer = NodeSdk.layer(() => ({ resource: { serviceName: "nodecg" }, spanProcessor: { forceFlush: () => Promise.resolve(), onStart: (span) => { effect.Runtime.runSync(runtime, effect.Effect.logTrace(`▶️ ${span.name}`)); }, onEnd: (span) => { const formattedDuration = (0, effect.pipe)(span.duration, effect.Duration.toMillis, effect.Number.round(0), effect.Duration.millis, effect.Duration.format, effect.String.split(" "), effect.Array.take(2), effect.Array.join(" ")); let log$7 = `${span.status.code === SpanStatusCode.ERROR ? "❌" : "✅"} ${span.name} (${formattedDuration})`; if (span.status.code === SpanStatusCode.ERROR) log$7 += ` ${span.status.message}`; effect.Runtime.runSync(runtime, effect.Effect.logTrace(log$7)); }, shutdown: () => Promise.resolve() } })); return yield* effect.Effect.provide(effect$1, layer); }); //#endregion //#region src/types/logger-interface.ts const LogLevels = [ "verbose", "debug", "info", "warn", "error", "silent" ]; const LogLevel = { Trace: "verbose", Debug: "debug", Info: "info", Warn: "warn", Error: "error", Silent: "silent" }; //#endregion //#region src/types/nodecg-config-schema.ts const parsedArgv = zod.z.object({ bundlesEnabled: zod.z.string().optional().transform((val) => val?.split(",")), bundlesDisabled: zod.z.string().optional().transform((val) => val?.split(",")), bundlesPaths: zod.z.string().optional().transform((val) => val?.split(",")) }).parse(yargs.argv); const nodecgConfigSchema = zod.z.object({ host: zod.z.string().default("0.0.0.0").describe("The IP address or hostname that NodeCG should bind to."), port: zod.z.number().int().positive().default(9090).describe("The port that NodeCG should listen on."), baseURL: zod.z.string().optional().describe("The URL of this instance. Used for things like cookies. Defaults to HOST:PORT. If you use a reverse proxy, you'll likely need to set this value."), exitOnUncaught: zod.z.boolean().default(true).describe("Whether or not to exit on uncaught exceptions."), logging: zod.z.object({ console: zod.z.object({ enabled: zod.z.boolean().default(true).describe("Whether to enable console logging."), level: zod.z.enum(LogLevels).default("info").describe("The log level to use."), timestamps: zod.z.boolean().default(true).describe("Whether to add timestamps to the console logging."), replicants: zod.z.boolean().default(false).describe("Whether to enable logging of the Replicants subsystem. Very spammy.") }).default({ enabled: true }), file: zod.z.object({ enabled: zod.z.boolean().default(false).describe("Whether to enable file logging."), level: zod.z.enum(LogLevels).default("info").describe("The log level to use."), path: zod.z.string().default("logs/nodecg.log").describe("The filepath to log to."), timestamps: zod.z.boolean().default(true).describe("Whether to add timestamps to the file logging."), replicants: zod.z.boolean().default(false).describe("Whether to enable logging of the Replicants subsystem. Very spammy.") }).default({ enabled: false }) }).default({ console: {} }), bundles: zod.z.object({ enabled: zod.z.array(zod.z.string()).nullable().default(parsedArgv.bundlesEnabled ?? null).describe("A whitelist array of bundle names."), disabled: zod.z.array(zod.z.string()).nullable().default(parsedArgv.bundlesDisabled ?? null).describe("A blacklist array of bundle names."), paths: zod.z.array(zod.z.string()).default(parsedArgv.bundlesPaths ?? []).describe("An array of additional paths where bundles are located.") }).default({ enabled: null, disabled: null, paths: [] }), login: zod.z.object({ enabled: zod.z.boolean().default(false).describe("Whether to enable login security."), sessionSecret: zod.z.string().optional().describe("The secret used to salt sessions."), forceHttpsReturn: zod.z.boolean().default(false).describe("Forces Steam & Twitch login return URLs to use HTTPS instead of HTTP. Useful in reverse proxy setups."), steam: zod.z.object({ enabled: zod.z.boolean().default(false).describe("Whether to enable Steam authentication."), apiKey: zod.z.string().optional().describe("A Steam API Key. Obtained from http://steamcommunity.com/dev/apikey"), allowedIds: zod.z.array(zod.z.string()).default([]).describe("Which 64 bit Steam IDs to allow. Can be obtained from https://steamid.io/") }).optional().refine((val) => val?.enabled ? typeof val.apiKey === "string" : true, { message: "\"login.steam.apiKey\" must be a string" }), twitch: zod.z.object({ enabled: zod.z.boolean().default(false).describe("Whether to enable Twitch authentication."), clientID: zod.z.string().optional().describe("A Twitch application ClientID http://twitch.tv/kraken/oauth2/clients/new"), clientSecret: zod.z.string().optional().describe("A Twitch application ClientSecret http://twitch.tv/kraken/oauth2/clients/new"), scope: zod.z.string().default("user_read").describe("A space-separated string of Twitch application permissions."), allowedUsernames: zod.z.array(zod.z.string()).default([]).describe("Which Twitch usernames to allow."), allowedIds: zod.z.array(zod.z.string()).default([]).describe("Which Twitch IDs to allow. Can be obtained from https://twitchinsights.net/checkuser") }).optional().refine((val) => val?.enabled ? typeof val.clientID === "string" : true, { message: "\"login.twitch.clientID\" must be a string" }).refine((val) => val?.enabled ? typeof val.clientSecret === "string" : true, { message: "\"login.twitch.clientID\" must be a string" }), discord: zod.z.object({ enabled: zod.z.boolean().default(false).describe("Whether to enable Discord authentication."), clientID: zod.z.string().optional().describe("A Discord application ClientID https://discord.com/developers/applications"), clientSecret: zod.z.string().optional().describe("A Discord application ClientSecret https://discord.com/developers/applications"), scope: zod.z.string().default("identify").describe("A space-separated string of Discord application scopes. https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes"), allowedUserIDs: zod.z.array(zod.z.string()).default([]).describe("Which Discord user IDs to allow."), allowedGuilds: zod.z.array(zod.z.object({ guildID: zod.z.string().describe("Users in this Discord Server are allowed to log in."), allowedRoleIDs: zod.z.array(zod.z.string()).default([]).describe("Additionally require one of the roles on the server to log in."), guildBotToken: zod.z.string().default("").describe("Discord bot token, needed if allowedRoleIDs is used.") })).default([]) }).optional().refine((val) => val?.enabled ? typeof val.clientID === "string" : true, { message: "\"login.discord.clientID\" must be a string" }).refine((val) => val?.enabled ? typeof val.clientSecret === "string" : true, { message: "\"login.discord.clientSecret\" must be a string" }), local: zod.z.object({ enabled: zod.z.boolean().default(false).describe("Enable Local authentication."), allowedUsers: zod.z.array(zod.z.object({ username: zod.z.string(), password: zod.z.string() })).default([]).describe("Which users can log in.") }).optional() }).refine((val) => val.enabled ? typeof val.sessionSecret === "string" : true, { message: "\"login.sessionSecret\" must be a string" }).default({ enabled: false }), ssl: zod.z.object({ enabled: zod.z.boolean().default(false).describe("Whether to enable SSL/HTTPS encryption."), allowHTTP: zod.z.boolean().default(false).describe("Whether to allow insecure HTTP connections while SSL is active."), keyPath: zod.z.string().optional().describe("The path to an SSL key file."), certificatePath: zod.z.string().optional().describe("The path to an SSL certificate file."), passphrase: zod.z.string().optional().describe("The passphrase for the provided key file.") }).default({ enabled: false }).refine((val) => val.enabled ? typeof val.keyPath === "string" : true, { message: "\"ssl.keyPath\" must be a string" }).refine((val) => val.enabled ? typeof val.certificatePath === "string" : true, { message: "\"ssl.certificatePath\" must be a string" }), sentry: zod.z.object({ enabled: zod.z.boolean().default(true).describe("Whether to enable Sentry error reporting."), dsn: zod.z.string().optional().describe("Your project's DSN, used to route alerts to the correct place.") }).default({ enabled: false }).refine((val) => val.enabled ? typeof val.dsn === "string" : true, { message: "\"sentry.dsn\" must be a string" }) }).transform((val) => { const host = val.host === "0.0.0.0" ? "localhost" : val.host; return { ...val, baseURL: val.baseURL ?? `${host}:${val.port}` }; }); //#endregion //#region src/server/config/loader.ts const loadConfig = (cfgDirOrFile) => { let isFile = false; try { isFile = node_fs.lstatSync(cfgDirOrFile).isFile(); } catch (error) { if (error.code !== "ENOENT") throw error; } const cfgDir = isFile ? node_path.dirname(cfgDirOrFile) : cfgDirOrFile; const userCfg = (0, cosmiconfig.cosmiconfigSync)("nodecg", { searchPlaces: isFile ? [node_path.basename(cfgDirOrFile)] : [ "nodecg.json", "nodecg.yaml", "nodecg.yml", "nodecg.js", "nodecg.config.js" ], stopDir: cfgDir }).search(cfgDir)?.config ?? {}; if (userCfg?.bundles?.enabled && userCfg?.bundles?.disabled) throw new Error("nodecg.json may only contain EITHER bundles.enabled OR bundles.disabled, not both."); else if (!userCfg) console.info("[nodecg] No config found, using defaults."); const parseResult = nodecgConfigSchema.safeParse(userCfg); if (!parseResult.success) { console.error("[nodecg] Config invalid:", parseResult.error.errors[0]?.message); throw new Error(parseResult.error.errors[0]?.message); } const config$1 = parseResult.data; const filteredConfig$1 = { host: config$1.host, port: config$1.port, baseURL: config$1.baseURL, logging: { console: { enabled: config$1.logging.console.enabled, level: config$1.logging.console.level, timestamps: config$1.logging.console.timestamps, replicants: config$1.logging.console.replicants }, file: { enabled: config$1.logging.file.enabled, level: config$1.logging.file.level, timestamps: config$1.logging.file.timestamps, replicants: config$1.logging.file.replicants } }, login: { enabled: config$1.login.enabled }, sentry: { enabled: config$1.sentry.enabled, dsn: config$1.sentry.enabled ? config$1.sentry.dsn : void 0 } }; if (config$1.login.enabled && config$1.login.steam) filteredConfig$1.login.steam = { enabled: config$1.login.steam.enabled }; if (config$1.login.enabled && config$1.login.twitch) filteredConfig$1.login.twitch = { enabled: config$1.login.twitch.enabled, clientID: config$1.login.twitch.enabled ? config$1.login.twitch.clientID : void 0, scope: config$1.login.twitch.enabled ? config$1.login.twitch.scope : void 0 }; if (config$1.login.enabled && config$1.login.local) filteredConfig$1.login.local = { enabled: config$1.login.local.enabled }; if (config$1.login.enabled && config$1.login.discord) filteredConfig$1.login.discord = { enabled: config$1.login.discord.enabled, clientID: config$1.login.discord.enabled ? config$1.login.discord.clientID : void 0, scope: config$1.login.discord.enabled ? config$1.login.discord.scope : void 0 }; if (config$1.ssl) filteredConfig$1.ssl = { enabled: config$1.ssl.enabled }; return { config: (0, klona_json.klona)(config$1), filteredConfig: (0, klona_json.klona)(filteredConfig$1) }; }; //#endregion //#region src/server/config/index.ts const cfgDirectoryPath = yargs.argv.cfgPath ?? node_path.join(__nodecg_internal_util.rootPaths.getRuntimeRoot(), "cfg"); if (!node_fs.existsSync(cfgDirectoryPath)) node_fs.mkdirSync(cfgDirectoryPath, { recursive: true }); const { config, filteredConfig } = loadConfig(cfgDirectoryPath); const exitOnUncaught = config.exitOnUncaught; const sentryEnabled = config.sentry?.enabled; //#endregion //#region src/server/_effect/event-listener.ts const waitForEvent = (eventEmitter, eventName) => effect.Effect.async((resume) => { const handler = (...payload) => { resume(effect.Effect.succeed(payload)); }; eventEmitter.once(eventName, handler); return effect.Effect.sync(() => { eventEmitter.removeListener(eventName, handler); }); }); const listenToEvent = (eventEmitter, eventName, boundary) => effect.Effect.gen(function* () { const queue = yield* effect.Match.value(boundary).pipe(effect.Match.when(effect.Predicate.isNumber, (b) => effect.Queue.bounded(b)), effect.Match.when(effect.Predicate.isUndefined, () => effect.Queue.unbounded()), effect.Match.exhaustive); const handler = (...payload) => { effect.Queue.unsafeOffer(queue, payload); }; eventEmitter.addListener(eventName, handler); yield* effect.Effect.addFinalizer(() => effect.Effect.sync(() => { eventEmitter.removeListener(eventName, handler); })); return effect.Stream.fromQueue(queue); }); //#endregion //#region src/server/util/authcheck.ts /** * Express middleware that checks if the user is authenticated. */ const authCheck = async (req, res, next) => { try { if (!config.login?.enabled) { next(); return; } let { user } = req; let isUsingKeyOrSocketToken = false; let keyOrSocketTokenAuthenticated = false; if (req.query.key ?? req.cookies.socketToken) { isUsingKeyOrSocketToken = true; const apiKey = await res.locals.databaseAdapter.findApiKey(req.query.key ?? req.cookies.socketToken); if (!apiKey) { req.session?.destroy(() => { res.clearCookie("socketToken", { secure: req.secure, sameSite: req.secure ? "none" : void 0 }); res.clearCookie("connect.sid", { path: "/" }); res.clearCookie("io", { path: "/" }); res.redirect("/login"); }); return; } user = await res.locals.databaseAdapter.findUser(apiKey.user.id) ?? void 0; } if (!user) { if (req.session) req.session.returnTo = req.url; res.status(403).redirect("/login"); return; } const allowed = res.locals.databaseAdapter.isSuperUser(user); keyOrSocketTokenAuthenticated = isUsingKeyOrSocketToken && allowed; const provider = user.identities[0].provider_type; const providerAllowed = config.login?.[provider]?.enabled; if ((keyOrSocketTokenAuthenticated || req.isAuthenticated()) && allowed && providerAllowed) { let apiKey = user.apiKeys[0]; if (!apiKey) { apiKey = await res.locals.databaseAdapter.createApiKey(); user.apiKeys.push(apiKey); await res.locals.databaseAdapter.saveUser(user); } res.cookie("socketToken", apiKey.secret_key, { secure: req.secure, sameSite: req.secure ? "none" : void 0 }); next(); return; } if (req.session) req.session.returnTo = req.url; res.status(403).redirect("/login"); return; } catch (error) { next(error); } }; //#endregion //#region src/server/util/nodecg-package-json.ts function recursivelyFindPackageJson(dir = __dirname) { const packageJsonPath = node_path.default.join(dir, "package.json"); if (node_fs.default.existsSync(packageJsonPath)) { const packageJson = JSON.parse(node_fs.default.readFileSync(packageJsonPath, "utf-8")); if (packageJson.name === "nodecg") return packageJson; } const parentDir = node_path.default.dirname(dir); if (dir === parentDir) throw new Error("Could not find NodeCG root path"); return recursivelyFindPackageJson(parentDir); } const nodecgPackageJson = recursivelyFindPackageJson(__dirname); //#endregion //#region src/server/server/sentry-config.ts const baseSentryConfig = { dsn: config.sentry.enabled ? config.sentry.dsn : "", serverName: node_os.hostname(), version: nodecgPackageJson.version }; const sentryConfigRouter = effect.Effect.fn("sentryConfigRouter")(function* (bundleManager) { const bundleMetadata = []; const app = (0, express.default)(); yield* effect.Effect.forkScoped(waitForEvent(bundleManager, "ready").pipe(effect.Effect.andThen(() => effect.Effect.sync(() => { __sentry_node.configureScope((scope) => { bundleManager.all().forEach((bundle) => { bundleMetadata.push({ name: bundle.name, git: bundle.git, version: bundle.version }); }); scope.setExtra("bundles", bundleMetadata); }); })))); const gitChangedStream = yield* listenToEvent(bundleManager, "gitChanged"); yield* effect.Effect.forkScoped(gitChangedStream.pipe(effect.Stream.runForEach(([bundle]) => effect.Effect.sync(() => { const metadataToUpdate = bundleMetadata.find((data) => data.name === bundle.name); if (!metadataToUpdate) return; metadataToUpdate.git = bundle.git; metadataToUpdate.version = bundle.version; })))); app.get("/sentry.js", authCheck, (_req, res) => { res.type(".js"); res.render(node_path.join(__nodecg_internal_util.rootPaths.nodecgInstalledPath, "dist/server/templates/sentry.js.tmpl"), { baseSentryConfig, bundleMetadata }); }); return app; }); //#endregion //#region src/server/logger/logger.server.ts /** * A factory that configures and returns a Logger constructor. * * @returns A constructor used to create discrete logger instances. */ function loggerFactory(initialOpts = {}, sentry = void 0) { initialOpts = initialOpts || {}; initialOpts.console = initialOpts.console ?? {}; initialOpts.file = initialOpts.file ?? {}; initialOpts.file.path = initialOpts.file.path ?? "logs/nodecg.log"; const consoleTransport = new winston.default.transports.Console({ level: initialOpts.console.level ?? LogLevel.Info, silent: !initialOpts.console.enabled, stderrLevels: ["warn", "error"], format: winston.default.format.combine(winston.default.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), winston.default.format.errors({ stack: true }), winston.default.format.colorize(), winston.default.format.printf((info) => `${initialOpts?.console?.timestamps ? `${info.timestamp} - ` : ""}${info.level}: ${info.message}`)) }); const fileTransport = new winston.default.transports.File({ filename: initialOpts.file.path, level: initialOpts.file.level ?? LogLevel.Info, silent: !initialOpts.file.enabled, format: winston.default.format.combine(winston.default.format.timestamp(), winston.default.format.errors({ stack: true }), winston.default.format.printf((info) => `${initialOpts?.file?.timestamps ? `${info.timestamp} - ` : ""}${info.level}: ${info.message}`)) }); if (typeof initialOpts.file.path !== "undefined") { fileTransport.filename = initialOpts.file.path; if (!node_fs.default.existsSync(node_path.dirname(initialOpts.file.path))) node_fs.default.mkdirSync(node_path.dirname(initialOpts.file.path), { recursive: true }); } winston.default.addColors({ verbose: "green", debug: "cyan", info: "white", warn: "yellow", error: "red" }); const consoleLogger = winston.default.createLogger({ transports: [consoleTransport], levels: { verbose: 4, trace: 4, debug: 3, info: 2, warn: 1, error: 0 } }); const fileLogger = winston.default.createLogger({ transports: [fileTransport], levels: { verbose: 4, trace: 4, debug: 3, info: 2, warn: 1, error: 0 } }); /** * Constructs a new Logger instance that prefixes all output with the given name. * @param name {String} - The label to prefix all output of this logger with. * @returns {Object} - A Logger instance. * @constructor */ return class Logger$2 { static _consoleLogger = consoleLogger; static _fileLogger = fileLogger; static _shouldConsoleLogReplicants = Boolean(initialOpts.console?.replicants); static _shouldFileLogReplicants = Boolean(initialOpts.file?.replicants); constructor(name) { this.name = name; this.name = name; } trace(...args) { [consoleLogger, fileLogger].forEach((logger$1) => logger$1.verbose(`[${this.name}] ${(0, node_util.format)(args[0], ...args.slice(1))}`)); } debug(...args) { [consoleLogger, fileLogger].forEach((logger$1) => logger$1.debug(`[${this.name}] ${(0, node_util.format)(args[0], ...args.slice(1))}`)); } info(...args) { [consoleLogger, fileLogger].forEach((logger$1) => logger$1.info(`[${this.name}] ${(0, node_util.format)(args[0], ...args.slice(1))}`)); } warn(...args) { [consoleLogger, fileLogger].forEach((logger$1) => logger$1.warn(`[${this.name}] ${(0, node_util.format)(args[0], ...args.slice(1))}`)); } error(...args) { [consoleLogger, fileLogger].forEach((logger$1) => logger$1.error(`[${this.name}] ${(0, node_util.format)(args[0], ...args.slice(1))}`)); if (sentry) { const formattedArgs = args.map((argument) => typeof argument === "object" ? (0, node_util.inspect)(argument, { depth: null, showProxy: true }) : argument); sentry.captureException(/* @__PURE__ */ new Error(`[${this.name}] ` + (0, node_util.format)(formattedArgs[0], ...formattedArgs.slice(1)))); } } replicants(...args) { if (Logger$2._shouldConsoleLogReplicants) consoleLogger.info(`[${this.name}] ${(0, node_util.format)(args[0], ...args.slice(1))}`); if (Logger$2._shouldFileLogReplicants) fileLogger.info(`[${this.name}] ${(0, node_util.format)(args[0], ...args.slice(1))}`); } }; } //#endregion //#region src/server/logger/index.ts let Logger; if (config.sentry?.enabled) Logger = loggerFactory(config.logging, __sentry_node); else Logger = loggerFactory(config.logging); function createLogger(name) { return new Logger(name); } //#endregion //#region src/server/server/login/index.ts const log$6 = createLogger("login"); const protocol = config.ssl?.enabled || config.login.enabled && config.login.forceHttpsReturn ? "https" : "http"; async function makeDiscordAPIRequest(guild, userID) { const res = await fetch(`https://discord.com/api/v8/guilds/${guild.guildID}/members/${userID}`, { headers: { Authorization: `Bot ${guild.guildBotToken}` } }); const data = await res.json(); if (res.status === 200) return [ guild, false, data ]; return [ guild, true, data ]; } function createMiddleware(db, callbacks) { passport.default.serializeUser((user, done) => { done(null, user.id); }); passport.default.deserializeUser(async (id, done) => { try { done(null, await db.findUser(id)); } catch (error) { done(error); } }); if (config.login.enabled && config.login.steam?.enabled && config.login.steam.apiKey) { const steamLoginConfig = config.login.steam; const apiKey = config.login.steam.apiKey; passport.default.use(new passport_steam.default({ returnURL: `${protocol}://${config.baseURL}/login/auth/steam`, realm: `${protocol}://${config.baseURL}/login/auth/steam`, apiKey }, async (_, profile, done) => { try { const roles = []; if (steamLoginConfig?.allowedIds?.includes(profile.id)) { log$6.info("(Steam) Granting \"%s\" (%s) access", profile.id, profile.displayName); roles.push(await db.getSuperUserRole()); } else log$6.info("(Steam) Denying \"%s\" (%s) access", profile.id, profile.displayName); done(void 0, await db.upsertUser({ name: profile.displayName, provider_type: "steam", provider_hash: profile.id, roles })); return; } catch (error) { done(error); } })); } if (config.login.enabled && config.login.twitch?.enabled) { const twitchLoginConfig = config.login.twitch; const scopesArray = twitchLoginConfig.scope.split(" "); if (!scopesArray.includes("user:read:email")) scopesArray.push("user:read:email"); const concatScopes = scopesArray.join(" "); passport.default.use(new passport_twitch_helix.Strategy({ clientID: twitchLoginConfig.clientID, clientSecret: twitchLoginConfig.clientSecret, callbackURL: `${protocol}://${config.baseURL}/login/auth/twitch`, scope: concatScopes, customHeaders: { "Client-ID": twitchLoginConfig.clientID } }, async (accessToken, refreshToken, profile, done) => { try { const roles = []; if (twitchLoginConfig.allowedUsernames?.includes(profile.username) ?? twitchLoginConfig.allowedIds?.includes(profile.id)) { log$6.info("(Twitch) Granting %s access", profile.username); roles.push(await db.getSuperUserRole()); } else log$6.info("(Twitch) Denying %s access", profile.username); done(void 0, await db.upsertUser({ name: profile.displayName, provider_type: "twitch", provider_hash: profile.id, provider_access_token: accessToken, provider_refresh_token: refreshToken, roles })); return; } catch (error) { done(error); } })); } if (config.login.enabled && config.login.discord?.enabled) { const discordLoginConfig = config.login.discord; const scopeArray = discordLoginConfig.scope.split(" "); if (!scopeArray.includes("identify")) scopeArray.push("identify"); if (!scopeArray.includes("guilds") && discordLoginConfig.allowedGuilds) scopeArray.push("guilds"); const scope = scopeArray.join(" "); passport.default.use(new passport_discord.Strategy({ clientID: discordLoginConfig.clientID, clientSecret: discordLoginConfig.clientSecret, callbackURL: `${protocol}://${config.baseURL}/login/auth/discord`, scope }, async (accessToken, refreshToken, profile, done) => { if (!discordLoginConfig) { done(/* @__PURE__ */ new Error("Discord login config was impossibly undefined.")); return; } let allowed = false; if (discordLoginConfig.allowedUserIDs?.includes(profile.id)) allowed = true; else if (discordLoginConfig.allowedGuilds) { const intersectingGuilds = discordLoginConfig.allowedGuilds.filter((allowedGuild) => profile.guilds?.some((profileGuild) => profileGuild.id === allowedGuild.guildID)); const guildRequests = []; for (const intersectingGuild of intersectingGuilds) if (!intersectingGuild.allowedRoleIDs || intersectingGuild.allowedRoleIDs.length === 0) allowed = true; else guildRequests.push(makeDiscordAPIRequest(intersectingGuild, profile.id)); if (!allowed) { const guildsData = await Promise.all(guildRequests); for (const [guildWithRoles, err, memberResponse] of guildsData) { if (err) { log$6.warn(`Got error while trying to get guild ${guildWithRoles.guildID} (Make sure you're using the correct bot token and guild id): ${JSON.stringify(memberResponse)}`); continue; } if (guildWithRoles.allowedRoleIDs.filter((allowedRole) => memberResponse.roles.includes(allowedRole)).length > 0) { allowed = true; break; } } } } else allowed = false; const roles = []; if (allowed) { log$6.info("(Discord) Granting %s#%s (%s) access", profile.username, profile.discriminator, profile.id); roles.push(await db.getSuperUserRole()); } else log$6.info("(Discord) Denying %s#%s (%s) access", profile.username, profile.discriminator, profile.id); done(void 0, await db.upsertUser({ name: `${profile.username}#${profile.discriminator}`, provider_type: "discord", provider_hash: profile.id, provider_access_token: accessToken, provider_refresh_token: refreshToken, roles })); })); } if (config.login.enabled && config.login.local?.enabled && config.login.sessionSecret) { const { sessionSecret, local: { allowedUsers } } = config.login; const hashes = node_crypto.getHashes(); passport.default.use(new passport_local.Strategy({ usernameField: "username", passwordField: "password", session: false }, async (username, password, done) => { try { const roles = []; const foundUser = allowedUsers?.find((u) => u.username === username); let allowed = false; if (foundUser) { const match = /^([^:]+):(.+)$/.exec(foundUser.password ?? ""); let expected = foundUser.password; let actual = password; if (match && hashes.includes(match[1])) { expected = match[2]; actual = node_crypto.createHmac(match[1], sessionSecret).update(actual, "utf8").digest("hex"); } if (expected === actual) { allowed = true; roles.push(await db.getSuperUserRole()); } } log$6.info("(Local) %s \"%s\" access", allowed ? "Granting" : "Denying", username); done(void 0, await db.upsertUser({ name: username, provider_type: "local", provider_hash: username, roles })); return; } catch (error) { done(error); } })); } const app = (0, express.default)(); const redirectPostLogin = (req, res) => { const url = req.session?.returnTo ?? "/dashboard"; delete req.session.returnTo; res.redirect(url); app.emit("login", req.user); if (req.user) callbacks.onLogin(req.user); }; if (!config.login.enabled || !config.login.sessionSecret) throw new Error("no session secret defined, can't salt sessions, not safe, aborting"); app.use((0, cookie_parser.default)(config.login.sessionSecret)); const sessionMiddleware = (0, express_session.default)({ resave: false, saveUninitialized: false, secret: config.login.sessionSecret, cookie: { path: "/", httpOnly: true, secure: config.ssl?.enabled } }); app.use(sessionMiddleware); app.use(passport.default.initialize()); app.use(passport.default.session()); app.use("/login", express.default.static(node_path.join(__nodecg_internal_util.rootPaths.nodecgInstalledPath, "dist/client/login"))); app.get("/login", (req, res) => { if (req.user && db.isSuperUser(req.user)) res.redirect("/dashboard"); else res.render(node_path.join(__nodecg_internal_util.rootPaths.nodecgInstalledPath, "dist/server/templates/login.tmpl"), { user: req.user, config }); }); app.get("/authError", (req, res) => { res.render(node_path.join(__nodecg_internal_util.rootPaths.nodecgInstalledPath, "dist/server/templates/authError.tmpl"), { message: req.query.message, code: req.query.code, viewUrl: req.query.viewUrl }); }); app.get("/login/steam", passport.default.authenticate("steam")); app.get("/login/auth/steam", passport.default.authenticate("steam", { failureRedirect: "/login" }), redirectPostLogin); app.get("/login/twitch", passport.default.authenticate("twitch")); app.get("/login/auth/twitch", passport.default.authenticate("twitch", { failureRedirect: "/login" }), redirectPostLogin); app.get("/login/discord", passport.default.authenticate("discord")); app.get("/login/auth/discord", passport.default.authenticate("discord", { failureRedirect: "/login" }), redirectPostLogin); app.get("/login/local", passport.default.authenticate("local")); app.post("/login/local", passport.default.authenticate("local", { failureRedirect: "/login" }), redirectPostLogin); app.get("/logout", (req, res) => { app.emit("logout", req.user); req.session?.destroy(() => { res.clearCookie("connect.sid", { path: "/" }); res.clearCookie("io", { path: "/" }); res.clearCookie("socketToken", { secure: req.secure, sameSite: req.secure ? "none" : void 0 }); res.redirect("/login"); }); if (req.user) callbacks.onLogout(req.user); }); return { app, sessionMiddleware }; } //#endregion //#region src/shared/utils/errors.ts /** * Make a string out of an error (or other equivalents), * including any additional data such as stack trace if available. * Safe to use on unknown inputs. */ function stringifyError(error, noStack = false) { const o = stringifyErrorInner(error); if (noStack || !o.stack) return o.message; return `${o.message}, ${o.stack}`; } function stringifyErrorInner(error) { let message; let stack; if (typeof error === "string") message = error; else if (error === null) message = "null"; else if (error === void 0) message = "undefined"; else if (error && typeof error === "object") if (typeof error.error === "object" && error.error.message) { message = error.error.message; stack = error.error.stack; } else if (error.reason) if (error.reason.message) { message = error.reason.message; stack = error.reason.stack || error.reason.reason; } else { message = error.reason; stack = error.stack; } else if (error.message) { message = error.message; stack = error.stack; } else if (error.details) message = error.details; else try { message = JSON.stringify(error); } catch (e) { message = `${error} (stringifyError: ${e})`; } else message = `${error}`; message = `${message}`; return { message, stack }; } //#endregion //#region src/server/util/throttle-name.ts const timers = /* @__PURE__ */ new Map(); const queued = /* @__PURE__ */ new Set(); /** * A standard throttle, but uses a string `name` as the key instead of the callback. */ function throttleName(name, callback, duration = 500) { if (timers.get(name)) { queued.add(name); return; } callback(); timers.set(name, setTimeout(() => { timers.delete(name); if (queued.has(name)) { queued.delete(name); callback(); } }, duration)); } //#endregion //#region src/shared/typed-emitter.ts var TypedEmitter = class { _emitter = new events.EventEmitter(); addListener(eventName, fn) { this._emitter.addListener(eventName, fn); } on(eventName, fn) { this._emitter.on(eventName, fn); } off(eventName, fn) { this._emitter.off(eventName, fn); } removeListener(eventName, fn) { this._emitter.removeListener(eventName, fn); } emit(eventName, ...params) { this._emitter.emit(eventName, ...params); } once(eventName, fn) { this._emitter.once(eventName, fn); } setMaxListeners(max) { this._emitter.setMaxListeners(max); } listenerCount(eventName) { return this._emitter.listenerCount(eventName); } listeners(eventName) { return this._emitter.listeners(eventName); } }; //#endregion //#region src/shared/utils/compileJsonSchema.ts const options = { allErrors: true, verbose: true, strict: "log" }; const ajv$1 = { draft04: (0, ajv_formats.default)(new ajv_draft_04.default(options)), draft07: (0, ajv_formats.default)(new ajv.default(options)), "draft2019-09": (0, ajv_formats.default)(new ajv_dist_2019.default(options)), "draft2020-12": (0, ajv_formats.default)(new ajv_dist_2020.default(options)) }; function compileJsonSchema(schema) { const schemaVersion = extractSchemaVersion(schema); if (schemaVersion.includes("draft-04")) return ajv$1.draft04.compile(schema); if (schemaVersion.includes("draft-07")) return ajv$1.draft07.compile(schema); if (schemaVersion.includes("draft/2019-09")) return ajv$1["draft2019-09"].compile(schema); if (schemaVersion.includes("draft/2020-12")) return ajv$1["draft2020-12"].compile(schema); throw new Error(`Unsupported JSON Schema version "${schemaVersion}"`); } function formatJsonSchemaErrors(schema, errors) { const schemaVersion = extractSchemaVersion(schema); if (schemaVersion.includes("draft-04")) return ajv$1.draft04.errorsText(errors).replace(/^data\//gm, ""); if (schemaVersion.includes("draft-07")) return ajv$1.draft07.errorsText(errors).replace(/^data\//gm, ""); if (schemaVersion.includes("draft/2019-09")) return ajv$1["draft2019-09"].errorsText(errors).replace(/^data\//gm, ""); if (schemaVersion.includes("draft/2020-12")) return ajv$1["draft2020-12"].errorsText(errors).replace(/^data\//gm, ""); throw new Error(`Unsupported JSON Schema version "${schemaVersion}"`); } function getSchemaDefault(schema, labelForDebugging) { try { return (0, __nodecg_json_schema_defaults.default)(schema); } catch (error) { throw new Error(`Error generating default value(s) for schema "${labelForDebugging}":\n\t${stringifyError(error)}`); } } const getSchemaDefaultFp = (schema, labelForDebugging) => fp_ts_Either.tryCatch(() => (0, __nodecg_json_schema_defaults.default)(schema), (error) => /* @__PURE__ */ new Error(`Error generating default value(s) for schema "${labelForDebugging}":\n\t${stringifyError(error)}`)); function extractSchemaVersion(schema) { const defaultVersion = "https://json-schema.org/draft-04/schema"; const extractedVersion = schema.$schema; return typeof extractedVersion === "string" ? extractedVersion : defaultVersion; } //#endregion //#region src/shared/utils/isBrowser.ts function isBrowser() { return typeof globalThis.window !== "undefined"; } function isWorker() { return typeof globalThis.WorkerGlobalScope !== "undefined" && self instanceof globalThis.WorkerGlobalScope; } //#endregion //#region src/shared/replicants.shared.ts /** * If you're wondering why some things are prefixed with "_", * but not marked as protected or private, this is because our Proxy * trap handlers need to access these parts of the Replicant internals, * but don't have access to private or protected members. * * So, we code this like its 2010 and just use "_" on some public members. */ var AbstractReplicant = class extends TypedEmitter { name; namespace; opts; revision = 0; log; schema; schemaSum; status = "undeclared"; validationErrors = []; _value; _oldValue; _operationQueue = []; _pendingOperationFlush = false; constructor(name, namespace, opts = {}) { super(); if (!name || typeof name !== "string") throw new Error("Must supply a name when instantiating a Replicant"); if (!namespace || typeof namespace !== "string") throw new Error("Must supply a namespace when instantiating a Replicant"); if (typeof opts.persistent === "undefined") opts.persistent = true; if (typeof opts.persistenceInterval === "undefined") opts.persistenceInterval = DEFAULT_PERSISTENCE_INTERVAL; this.name = name; this.namespace = namespace; this.opts = opts; const originalOnce = this.once.bind(this); this.once = (event, listener) => { if (event === "change" && this.status === "declared") { listener(this.value); return; } return originalOnce(event, listener); }; /** * When a new "change" listener is added, chances are that the developer wants it to be initialized ASAP. * However, if this replicant has already been declared previously in this context, their "change" * handler will *not* get run until another change comes in, which may never happen for Replicants * that change very infrequently. * To resolve this, we immediately invoke all new "change" handlers if appropriate. */ this.on("newListener", (event, listener) => { if (event === "change" && this.status === "declared") listener(this.value); }); } /** * If the operation is an array mutator method, call it on the target array with the operation arguments. * Else, handle it with objectPath. */ _applyOperation(operation) { ignoreProxy(this); let result; const path$13 = pathStrToPathArr(operation.path); if (ARRAY_MUTATOR_METHODS.includes(operation.method)) { if (typeof this.value !== "object" || this.value === null) throw new Error(`expected replicant "${this.namespace}:${this.name}" to have a value with type "object", got "${typeof this.value}" instead`); const arr = object_path.default.get(this.value, path$13); if (!Array.isArray(arr)) throw new Error(`expected to find an array in replicant "${this.namespace}:${this.name}" at path "${operation.path}"`); result = arr[operation.method].apply(arr, "args" in operation && "mutatorArgs" in operation.args ? operation.args.mutatorArgs : []); proxyRecursive(this, arr, operation.path); } else switch (operation.method) { case "overwrite": { const { newValue } = operation.args; this[isBrowser() || isWorker() ? "value" : "_value"] = proxyRecursive(this, newValue, operation.path); result = true; break; } case "add": case "update": { path$13.push(operation.args.prop); let { newValue } = operation.args; if (typeof newValue === "object") newValue = proxyRecursive(this, newValue, pathArrToPathStr(path$13)); result = object_path.default.set(this.value, path$13, newValue); break; } case "delete": if (path$13.length === 0 || object_path.default.has(this.value, path$13)) { const target = object_path.default.get(this.value, path$13); result = delete target[operation.args.prop]; } break; default: /* istanbul ignore next */ throw new Error(`Unexpected operation method "${operation.method}"`); } resumeProxy(this); return result; } /** * Used to validate the new value of a replicant. * * This is a stub that will be replaced if a Schema is available. */ validate = () => true; /** * Generates a JSON Schema validator function from the `schema` property of the provided replicant. * @param replicant {object} - The Replicant to perform the operation on. * @returns {function} - The generated validator function. */ _generateValidator() { const { schema } = this; if (!schema) throw new Error("can't generate a validator for a replicant which lacks a schema"); let validate; try { validate = compileJsonSchema(schema); } catch (error) { throw new Error(`Error compiling JSON Schema for Replicant "${this.namespace}:${this.name}":\n\t${stringifyError(error)}`); } /** * Validates a value against the current Replicant's schema. * Throws when the value fails validation. * @param [value=replicant.value] {*} - The value to validate. Defaults to the replicant's current value. * @param [opts] {Object} * @param [opts.throwOnInvalid = true] {Boolean} - Whether or not to immediately throw when the provided value fails validation against the schema. */ return function(value = this.value, { throwOnInvalid = true } = {}) { const valid = validate(value); if (!valid) { this.validationErrors = validate.errors; if (throwOnInvalid) throw new Error(`Invalid value rejected for replicant "${this.name}" in namespace "${this.namespace}":\n${formatJsonSchemaErrors(schema, validate.errors)}`); } return valid; }; } }; const proxyMetadataMap = /* @__PURE__ */ new WeakMap(); const metadataMap = /* @__PURE__ */ new WeakMap(); const proxySet = /* @__PURE__ */ new WeakSet(); const ignoringProxy = /* @__PURE__ */ new WeakSet(); const ARRAY_MUTATOR_METHODS = [ "copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift" ]; /** * The default persistence interval, in milliseconds. */ const DEFAULT_PERSISTENCE_INTERVAL = 100; function ignoreProxy(replicant) { ignoringProxy.add(replicant); } function resumeProxy(replicant) { ignoringProxy.delete(replicant); } function isIgnoringProxy(replicant) { return ignoringProxy.has(replicant); } const deleteTrap = function(target, prop) { const metadata = metadataMap.get(target); if (!metadata) throw new Error("arrived at delete trap without any metadata"); const { replicant } = metadata; if (isIgnoringProxy(replicant)) return delete target[prop]; if (!{}.hasOwnProperty.call(target, prop)) return true; if (replicant.schema) { const valueClone = (0, klona_json.klona)(replicant.value); const targetClone = object_path.default.get(valueClone, pathStrToPathArr(metadata.path)); delete targetClone[prop]; replicant.validate(valueClone); } replicant._addOperation({ path: metadata.path, method: "delete", args: { prop } }); if (!isBrowser() && !isWorker()) return delete target[prop]; }; const CHILD_ARRAY_HANDLER = { get(target, prop) { const metadata = metadataMap.get(target); if (!metadata) throw new Error("arrived at get trap without any metadata"); const { replicant } = metadata; if (isIgnoringProxy(replicant)) return target[prop]; if ({}.hasOwnProperty.call(Array.prototype, prop) && typeof Array.prototype[prop] === "function" && target[prop] === Array.prototype[prop] && ARRAY_MUTATOR_METHODS.includes(prop)) return (...args) => { if (replicant.schema) { const valueClone = (0, klona_json.klona)(replicant.value); const targetClone = object_path.default.get(valueClone, pathStrToPathArr(metadata.path)); targetClone[prop].apply(targetClone, args); replicant.validate(valueClone); } if (isBrowser() || isWorker()) metada