1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-11-22 22:12:45 +01:00
Peacock/components/index.ts

683 lines
24 KiB
TypeScript

/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2023 The Peacock Project Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* eslint-disable no-inner-declarations */
// noinspection RequiredAttributes
// load flags as soon as possible
import { getFlag, loadFlags } from "./flags"
loadFlags()
import { setFlagsFromString } from "v8"
import { program } from "commander"
import express, { Request, Router } from "express"
import http from "http"
import {
checkForUpdates,
extractToken,
handleAxiosError,
IS_LAUNCHER,
jokes,
PEACOCKVER,
PEACOCKVERSTRING,
ServerVer,
} from "./utils"
import { getConfig, getSwizzleable, swizzle } from "./configSwizzleManager"
import { handleOauthToken } from "./oauthToken"
import type {
RequestWithJwt,
S2CEventWithTimestamp,
ServerConnectionConfig,
} from "./types/types"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
import { join } from "path"
import { log, loggingMiddleware, LogLevel } from "./loggingInterop"
import { eventRouter } from "./eventHandler"
import { contractRoutingRouter } from "./contracts/contractRouting"
import { profileRouter } from "./profileHandler"
import { menuDataRouter, preMenuDataRouter } from "./menuData"
import { menuSystemPreRouter, menuSystemRouter } from "./menus/menuSystem"
import { legacyEventRouter } from "./2016/legacyEventRouter"
import { legacyMenuSystemRouter } from "./2016/legacyMenuSystem"
import { _theLastYardbirdScpc, controller } from "./controller"
import {
STEAM_NAMESPACE_2016,
STEAM_NAMESPACE_2018,
STEAM_NAMESPACE_2021,
STEAM_NAMESPACE_SCPC,
} from "./platformEntitlements"
import { legacyProfileRouter } from "./2016/legacyProfileRouter"
import { legacyMenuDataRouter } from "./2016/legacyMenuData"
import { legacyContractRouter } from "./2016/legacyContractHandler"
import { initRp } from "./discordRp"
import random from "random"
import { generateUserCentric } from "./contracts/dataGen"
import { json as jsonMiddleware, urlencoded } from "body-parser"
import { loadoutRouter, loadouts } from "./loadouts"
import { setupHotListener } from "./hotReloadService"
import type { AxiosError } from "axios"
import serveStatic from "serve-static"
import { webFeaturesRouter } from "./webFeatures"
import { toolsMenu } from "./tools"
import picocolors from "picocolors"
import { multiplayerRouter } from "./multiplayer/multiplayerService"
import { multiplayerMenuDataRouter } from "./multiplayer/multiplayerMenuData"
import { pack, unpack } from "msgpackr"
import { liveSplitManager } from "./livesplit/liveSplitManager"
import { cheapLoadUserData } from "./databaseHandler"
import { reportRouter } from "./contracts/reportRouting"
// welcome to the bleeding edge
setFlagsFromString("--harmony")
const host = process.env.HOST || "0.0.0.0"
const port = process.env.PORT || 80
function uncaught(error: Error): void {
if (
(error.message || "").includes("EADDRINUSE") ||
(error.message || "").includes("EACCES") ||
(error.stack || "").includes("EADDRINUSE")
) {
log(LogLevel.ERROR, `Failed to use the server on ${host}:${port}!`)
log(
LogLevel.ERROR,
"This is likely due to one of the following reasons:",
)
log(LogLevel.ERROR, ` - Peacock is already running on this port`)
log(
LogLevel.ERROR,
` - Another app is already using this port (like IIS server)`,
)
log(
LogLevel.ERROR,
` - Your user account doesn't have permission (firewall can block it)`,
)
process.exit(1)
}
if ((error as AxiosError).isAxiosError) {
handleAxiosError(error as AxiosError)
}
log(LogLevel.ERROR, error.message)
error.stack && log(LogLevel.ERROR, error.stack)
}
process.on("uncaughtException", uncaught)
const app = express()
app.use(loggingMiddleware)
app.use("/_wf", webFeaturesRouter)
app.get("/", (req: Request, res) => {
if (PEACOCK_DEV) {
res.contentType("text/html")
res.send(
'<html lang="en">PEACOCK_DEV active, please run "yarn webui start" to start the web UI on port 3000 and access it there.</html>',
)
} else {
const data = readFileSync("webui/dist/index.html").toString()
res.contentType("text/html")
res.send(data)
}
})
serveStatic.mime.define({ "application/javascript": ["js"] })
app.use("/assets", serveStatic("webui/dist/assets"))
// make sure all responses have a default content-type set
app.use(function (_req, res, next) {
res.contentType("text/plain")
next()
})
if (getFlag("loadoutSaving") === "PROFILES") {
app.use("/loadouts", loadoutRouter)
}
app.get(
"/config/:audience/:serverVersion(\\d+_\\d+_\\d+)",
(req: RequestWithJwt<{ issuer: string }>, res) => {
const proto = req.protocol
const config = getConfig("config", true) as ServerConnectionConfig
const serverhost = req.get("Host")
config.Versions[0].GAME_VER = req.params.serverVersion.startsWith("8")
? `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}`
: req.params.serverVersion.startsWith("7")
? "7.17.0"
: "6.74.0"
if (req.params.serverVersion.startsWith("8")) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"pc-prod_8"
}
if (req.params.serverVersion.startsWith("7")) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"pc-prod_7"
}
if (req.params.serverVersion.startsWith("6")) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"pc-prod_6"
}
if (req.query.issuer === STEAM_NAMESPACE_2021) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"steam-prod_8"
}
if (req.params.audience === "scpc-prod") {
log(LogLevel.DEBUG, "Entering special mode.")
// sniper challenge is a different game/audience
config.Versions[0].Name = "scpc-prod"
config.Versions[0].GAME_VER = "7.3.0"
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
"scpc-prod"
}
config.Versions[0].ISSUER_ID = req.query.issuer || "*"
config.Versions[0].SERVER_VER.Metrics.MetricsServerHost = `${proto}://${serverhost}`
config.Versions[0].SERVER_VER.Authentication.AuthenticationHost = `${proto}://${serverhost}`
config.Versions[0].SERVER_VER.Configuration.Url = `${proto}://${serverhost}/files/onlineconfig.json`
config.Versions[0].SERVER_VER.Configuration.AgreementUrl = `${proto}://${serverhost}/files/privacypolicy/hm3/privacypolicy.json`
config.Versions[0].SERVER_VER.Resources.ResourcesServicePath = `${proto}://${serverhost}/files`
config.Versions[0].SERVER_VER.GlobalAuthentication.AuthenticationHost = `${proto}://${serverhost}`
res.json(config)
},
)
app.get("/files/privacypolicy/hm3/privacypolicy_*.json", (req, res) => {
res.set("Content-Type", "application/octet-stream")
res.set("x-ms-meta-version", "20181001")
res.send(getConfig("privacypolicy", false))
})
app.post(
"/api/metrics/*",
jsonMiddleware({ limit: "10Mb" }),
(req: RequestWithJwt<never, S2CEventWithTimestamp[]>, res) => {
req.body.forEach((event) => {
controller.hooks.newMetricsEvent.call(event, req)
})
res.send()
},
)
app.use("/oauth/token", urlencoded())
app.post("/oauth/token", (req: RequestWithJwt, res) =>
handleOauthToken(req, res),
)
app.get("/files/onlineconfig.json", (req, res) => {
res.set("Content-Type", "application/octet-stream")
res.send(getConfig("onlineconfig", false))
})
app.get(
"/profiles/page//dashboard//Dashboard_Category_Sniper_Singleplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d",
(req: RequestWithJwt, res) => {
res.json({
template: getConfig("FrankensteinMmSpTemplate", false),
data: {
Item: {
Id: "ff9f46cf-00bd-4c12-b887-eac491c3a96d",
Type: "Contract",
Title: "UI_CONTRACT_HAWK_TITLE",
Date: new Date().toISOString(),
Data: generateUserCentric(
_theLastYardbirdScpc,
req.jwt.unique_name,
"h1",
),
},
},
})
},
)
// We handle this for now, but it's not used. For the future though.
app.get(
"/profiles/page//dashboard//Dashboard_Category_Sniper_Multiplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d",
(req: RequestWithJwt, res) => {
const template = getConfig("FrankensteinMmMpTemplate", false)
/* To enable multiplayer:
* Change MultiplayerNotSupported to false
* NOTE: REMOVING THIS FULLY WILL BREAK THE EDITED TEMPLATE!
*/
res.json({
template: template,
data: {
Item: {
Id: "ff9f46cf-00bd-4c12-b887-eac491c3a96d",
Type: "Contract",
Title: "UI_CONTRACT_HAWK_TITLE",
Date: new Date().toISOString(),
Disabled: true,
Data: {
...generateUserCentric(
_theLastYardbirdScpc,
req.jwt.unique_name,
"h1",
),
...{ MultiplayerNotSupported: true },
},
},
},
})
},
)
// NOTE! All routes attached after this point will be checked for a JWT or blob signature.
// If you are adding a route that does NOT require authentication, put it ABOVE this message!
app.use(
Router()
.use(
"/resources-:serverVersion(\\d+-\\d+)/",
(req: RequestWithJwt, res, next) => {
req.serverVersion = req.params.serverVersion
req.gameVersion = req.serverVersion.startsWith("8")
? "h3"
: req.serverVersion.startsWith("7") &&
// prettier-ignore
req.serverVersion !== "7.3.0"
? // prettier-ignore
"h2"
: // prettier-ignore
"h1"
if (req.serverVersion === "7.3.0") {
req.gameVersion = "scpc"
}
next("router")
},
)
// we're fine with skipping to the next router if we don't have auth
.use(extractToken, (req: RequestWithJwt, res, next) => {
switch (req.jwt?.pis) {
case "egp_io_interactive_hitman_the_complete_first_season":
case STEAM_NAMESPACE_2016:
case STEAM_NAMESPACE_SCPC:
req.serverVersion = "6-74"
break
case STEAM_NAMESPACE_2018:
req.serverVersion = "7-17"
break
case "fghi4567xQOCheZIin0pazB47qGUvZw4":
case STEAM_NAMESPACE_2021:
req.serverVersion = "8-11"
break
default:
res.status(400).json({ message: "no game data" })
return
}
req.gameVersion = req.serverVersion.startsWith("8")
? "h3"
: req.serverVersion.startsWith("7")
? "h2"
: "h1"
if (req.jwt?.aud === "scpc-prod") {
req.gameVersion = "scpc"
}
next()
}),
)
if (getFlag("developmentAllowRuntimeRestart")) {
app.use(async (req: RequestWithJwt, _res, next): Promise<void> => {
if (!req.jwt) {
next()
return
}
//Make sure the userdata is always loaded if a proper JWT token is available
await cheapLoadUserData(req.jwt.unique_name, req.gameVersion)
next()
})
}
function generateBlobConfig(req: RequestWithJwt) {
return {
bloburl: `${req.protocol}://${req.get("Host")}/resources-${
req.serverVersion
}/`,
blobsig: `?sv=2018-03-28&ver=${req.gameVersion}`,
blobsigduration: 7200000.0,
}
}
app.get(
"/authentication/api/configuration/Init?*",
extractToken,
(req: RequestWithJwt, res) => {
// configName=pc-prod&lockedContentDisabled=false&isFreePrologueUser=false&isIntroPackUser=false&isFullExperienceUser=false
res.json({
token: `${req.jwt.exp}-${req.jwt.nbf}-${req.jwt.platform}-${req.jwt.userid}`,
blobconfig: generateBlobConfig(req),
profileid: req.jwt.unique_name,
serverversion: `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}.${ServerVer._Revision}`,
servertimeutc: new Date().toISOString(),
ias: 2,
})
},
)
app.post(
"/authentication/api/userchannel/AuthenticationService/RenewBlobSignature",
(req: RequestWithJwt, res) => {
res.json(generateBlobConfig(req))
},
)
const legacyRouter = Router()
const primaryRouter = Router()
legacyRouter.use(
"/authentication/api/userchannel/EventsService/",
legacyEventRouter,
)
legacyRouter.use("/resources-(\\d+-\\d+)/", legacyMenuSystemRouter)
legacyRouter.use("/authentication/api/userchannel/", legacyProfileRouter)
legacyRouter.use("/profiles/page/", legacyMenuDataRouter)
legacyRouter.use(
"/authentication/api/userchannel/ContractsService/",
legacyContractRouter,
)
legacyRouter.use(
"/authentication/api/userchannel/ContractSessionsService/",
legacyContractRouter,
)
primaryRouter.use(
"/authentication/api/userchannel/MultiplayerService/",
multiplayerRouter,
)
primaryRouter.use("/authentication/api/userchannel/EventsService/", eventRouter)
primaryRouter.use(
"/authentication/api/userchannel/ContractsService/",
contractRoutingRouter,
)
primaryRouter.use(
"/authentication/api/userchannel/ReportingService/",
reportRouter,
)
primaryRouter.use("/authentication/api/userchannel/", profileRouter)
primaryRouter.use("/profiles/page/", preMenuDataRouter)
primaryRouter.use("/profiles/page", multiplayerMenuDataRouter)
primaryRouter.use("/profiles/page/", menuDataRouter)
primaryRouter.use("/resources-(\\d+-\\d+)/", menuSystemPreRouter)
primaryRouter.use("/resources-(\\d+-\\d+)/", menuSystemRouter)
app.use(
Router()
.use((req: RequestWithJwt, res, next) => {
if (req.shouldCease) {
return next("router")
}
if (req.serverVersion === "6-74" || req.serverVersion === "7-3") {
return next() // continue along h1router
}
next("router")
})
.use(legacyRouter),
Router()
.use((req: RequestWithJwt, res, next) => {
if (req.shouldCease) {
return next("router")
}
if (
["6-74", "7-3", "7-17", "8-11"].includes(
<string>req.serverVersion,
)
) {
return next() // continue along h3 router
}
next("router")
})
.use(primaryRouter),
)
app.all("*", (req, res) => {
log(LogLevel.WARN, `Unhandled URL: ${req.url}`)
res.status(404).send("Not found!")
})
program.description(
"The Peacock Project is a HITMAN™ World of Assassination Trilogy server built for general use.",
)
const PEECOCK_ART = `
███████████ ██████████ ██████████ █████████ ███████ █████████ █████ ████
░░███░░░░░███░░███░░░░░█░░███░░░░░█ ███░░░░░███ ███░░░░░███ ███░░░░░███░░███ ███░
░███ ░███ ░███ █ ░ ░███ █ ░ ███ ░░░ ███ ░░███ ███ ░░░ ░███ ███
░██████████ ░██████ ░██████ ░███ ░███ ░███░███ ░███████
░███░░░░░░ ░███░░█ ░███░░█ ░███ ░███ ░███░███ ░███░░███
░███ ░███ ░ █ ░███ ░ █░░███ ███░░███ ███ ░░███ ███ ░███ ░░███
█████ ██████████ ██████████ ░░█████████ ░░░███████░ ░░█████████ █████ ░░████
░░░░░ ░░░░░░░░░░ ░░░░░░░░░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░
`
function startServer(options: { hmr: boolean; pluginDevHost: boolean }): void {
checkForUpdates()
if (!IS_LAUNCHER) {
console.log(
Math.random() < 0.001
? PEECOCK_ART
: picocolors.greenBright(`
███████████ ██████████ █████████ █████████ ███████ █████████ █████ ████
░░███░░░░░███░░███░░░░░█ ███░░░░░███ ███░░░░░███ ███░░░░░███ ███░░░░░███░░███ ███░
░███ ░███ ░███ █ ░ ░███ ░███ ███ ░░░ ███ ░░███ ███ ░░░ ░███ ███
░██████████ ░██████ ░███████████ ░███ ░███ ░███░███ ░███████
░███░░░░░░ ░███░░█ ░███░░░░░███ ░███ ░███ ░███░███ ░███░░███
░███ ░███ ░ █ ░███ ░███ ░░███ ███░░███ ███ ░░███ ███ ░███ ░░███
█████ ██████████ █████ █████ ░░█████████ ░░░███████░ ░░█████████ █████ ░░████
░░░░░ ░░░░░░░░░░ ░░░░░ ░░░░░ ░░░░░░░░░ ░░░░░░░ ░░░░░░░░░ ░░░░░ ░░░░
`),
)
}
log(
LogLevel.INFO,
`This is Peacock v${PEACOCKVERSTRING} (rev ${PEACOCKVER}), with Node v${process.versions.node}.`,
)
// jokes lol
if (getFlag("jokes") === true) {
log(
LogLevel.INFO,
picocolors.yellowBright(
`${jokes[random.int(0, jokes.length - 1)]}`,
),
)
}
// make sure required folder structure is in place
for (const dir of [
"contractSessions",
"plugins",
"userdata",
"contracts",
join("userdata", "epicids"),
join("userdata", "steamids"),
join("userdata", "users"),
join("userdata", "h1", "steamids"),
join("userdata", "h1", "epicids"),
join("userdata", "h1", "users"),
join("userdata", "h2", "steamids"),
join("userdata", "h2", "users"),
join("userdata", "scpc", "users"),
join("userdata", "scpc", "steamids"),
join("images", "actors"),
join("images", "contracts"),
join("images", "contracts", "elusive"),
join("images", "contracts", "escalation"),
join("images", "contracts", "featured"),
join("images", "unlockables_override"),
]) {
if (existsSync(dir)) {
continue
}
log(LogLevel.DEBUG, `Creating missing directory ${dir}`)
mkdirSync(dir, { recursive: true })
}
if (options.hmr) {
log(LogLevel.DEBUG, "Experimental HMR enabled.")
// eslint-disable-next-line @typescript-eslint/require-await
setupHotListener("contracts", () => {
log(LogLevel.INFO, "Detected a change in contracts! Re-indexing...")
controller.index()
})
}
// once contracts directory is present, we are clear to boot
loadouts.init()
controller.boot(options.pluginDevHost)
const httpServer = http.createServer(app)
// @ts-expect-error Non-matching method sig
httpServer.listen(port, host)
log(LogLevel.INFO, "Server started.")
if (getFlag("discordRp") === true) {
initRp()
}
// initialize livesplit
liveSplitManager.init()
}
program.option(
"--hmr",
"enable experimental hot reloading of contracts",
getFlag("experimentalHMR") as boolean,
)
program.option(
"--plugin-dev-host",
"activate plugin development features - requires plugin dev workspace setup",
getFlag("developmentPluginDevHost") as boolean,
)
program.action(startServer)
program
.command("swizzle")
.option(
"-c, --config <name>",
"the config file to generate an override for",
"",
)
.option("--list", "get a list of config files that can be overridden")
.description(
"generates a file that overrides its internal config counterpart",
)
.action((args: { list: boolean; config: string }) => {
if (args.list) {
log(LogLevel.INFO, "The following configurations can be swizzled:")
getSwizzleable().forEach((swizzleable) => {
log(LogLevel.INFO, ` - ${swizzleable}`)
})
return
}
// doesn't want list, but hasn't specified a swizzleable
if (!args.list && args.config === "") {
log(LogLevel.ERROR, "No config specified! - Aborting.")
return process.exit(1)
}
return swizzle(args.config)
})
program
.command("tools")
.description("open the tools UI")
.action(() => {
toolsMenu()
})
program
.command("pack")
.argument("<input>", "input file to pack")
.option("-o, --output <path>", "where to output the packed file to", "")
.description("packs an input file into a Challenge Resource Package")
.action((input, options: { output: string }) => {
const outputPath =
options.output !== ""
? options.output
: input.replace(/\.[^/\\.]+$/, ".crp")
writeFileSync(
outputPath,
pack(JSON.parse(readFileSync(input).toString())),
)
log(LogLevel.INFO, `Packed "${input}" to "${outputPath}" successfully.`)
})
program
.command("unpack")
.argument("<input>", "input file to unpack")
.option("-o, --output <path>", "where to output the unpacked file to", "")
.description("unpacks a Challenge Resource Package")
.action((input, options: { output: string }) => {
const outputPath =
options.output !== ""
? options.output
: input.replace(/\.[^/\\.]+$/, ".json")
writeFileSync(outputPath, JSON.stringify(unpack(readFileSync(input))))
log(
LogLevel.INFO,
`Unpacked "${input}" to "${outputPath}" successfully.`,
)
})
program.parse(process.argv)