1
mirror of https://github.com/thepeacockproject/Peacock synced 2025-03-21 00:04:22 +01:00
Lennard Fonteijn 4031779a91 Added distinction between Mastery XP and Action XP
Added extended Profile Profile to main menu
Added support for Payout objectives on the score screen
Added flag for unlocking all shortcuts
Added flag for unlocking all Freelancer masteries
Added flag to allow Peacock to be restarted when the game is running and connected
Fixed issue where playstyle wasn't show properly
2023-03-19 14:15:19 -04:00

678 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"
// 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/", 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)