219 lines
5.7 KiB
TypeScript
219 lines
5.7 KiB
TypeScript
import { VERSIONS } from "./versions";
|
|
import { GetClassMethods } from "../utils/reflection";
|
|
import compression from "compression";
|
|
import { Router, json } from "express";
|
|
import { ChunithmVersions } from "lib/constants/game-versions";
|
|
import CreateLogCtx from "lib/logger/logger";
|
|
import { RequestLoggerMiddleware } from "lib/middleware/request-logger";
|
|
import { Config, Environment } from "lib/setup/config";
|
|
import getRawBody from "raw-body";
|
|
import { createDecipheriv, pbkdf2Sync } from "crypto";
|
|
import type { RequestHandler } from "express";
|
|
|
|
const logger = CreateLogCtx(__filename);
|
|
|
|
const router: Router = Router({ mergeParams: false });
|
|
|
|
router.use(compression({ threshold: 0 }));
|
|
|
|
router.use(async (req, res, next) => {
|
|
if ((req.headers["content-length"] ?? 0) === 0) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// /SDHD/210/UpsertUserAllApi
|
|
const splitPath = req.path.split("/");
|
|
const version = splitPath[2];
|
|
const versionNum = Number(version);
|
|
const endpoint = splitPath.at(-1);
|
|
|
|
if (!endpoint || !version || Number.isNaN(versionNum)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const isEncryptedRequest =
|
|
endpoint.length === 32 && [...endpoint].every((c) => "0123456789abcdefABCDEF".includes(c));
|
|
|
|
if (
|
|
!isEncryptedRequest &&
|
|
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
|
|
versionNum >= ChunithmVersions.CRYSTAL_PLUS
|
|
) {
|
|
logger.error(
|
|
"Rejecting unencrypted request, because the server is configured to accept only encrypted requests.",
|
|
{ version, endpoint }
|
|
);
|
|
|
|
return res.status(200).send({ stat: "0" });
|
|
}
|
|
|
|
if (!isEncryptedRequest) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
logger.debug("Received encrypted request.", { version, endpoint });
|
|
|
|
const keys = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[version];
|
|
|
|
if (!keys) {
|
|
logger.error("No keys were configured for the requested version.", {
|
|
version,
|
|
endpoint,
|
|
});
|
|
return res.status(200).send({ stat: "0" });
|
|
}
|
|
|
|
// Mostly as a convenience, so versions PARADISE LOST and older don't have to provide
|
|
// the path salt.
|
|
const userAgent = req.headers["user-agent"];
|
|
|
|
if (versionNum <= ChunithmVersions.PARADISE && userAgent) {
|
|
const unhashedEndpoint = userAgent.split("#")[0];
|
|
|
|
if (unhashedEndpoint) {
|
|
req.url = req.url.replace(endpoint, unhashedEndpoint);
|
|
} else {
|
|
logger.warn(
|
|
"User-Agent did not contain the unhashed endpoint for version PARADISE (LOST) or older?",
|
|
{ version, userAgent }
|
|
);
|
|
|
|
return res.status(200).send({ stat: "0" });
|
|
}
|
|
}
|
|
|
|
const key = keys[0];
|
|
const iv = keys[1];
|
|
|
|
if (!key || !iv) {
|
|
logger.error("Key or IV was not provided for the requested version.", {
|
|
version,
|
|
key,
|
|
iv,
|
|
});
|
|
|
|
return res.status(200).send({ stat: "0" });
|
|
}
|
|
|
|
const cipher = createDecipheriv("aes-256-cbc", Buffer.from(key, "hex"), Buffer.from(iv, "hex"));
|
|
const body = await getRawBody(req);
|
|
|
|
// eslint-disable-next-line require-atomic-updates
|
|
req.body = Buffer.concat([cipher.update(body), cipher.final()]);
|
|
|
|
// TODO: Encrypt response
|
|
|
|
next();
|
|
});
|
|
|
|
router.use((req, res, next) => {
|
|
if (Environment.nodeEnv === "dev" && req.header("x-debug") !== undefined) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Get express.json() to recognize
|
|
req.headers["content-encoding"] = "deflate";
|
|
|
|
next();
|
|
});
|
|
|
|
// All requests to the title server will be JSON, I promise!
|
|
router.use(json({ type: (_) => true }));
|
|
|
|
router.use((req, res, next) => {
|
|
// Always mount an empty req body. We operate under the assumption that req.body is
|
|
// always defined as atleast an object.
|
|
if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) {
|
|
req.body = {};
|
|
}
|
|
|
|
// req.safeBody *is* just a type-safe req.body!
|
|
req.safeBody = req.body as Record<string, unknown>;
|
|
|
|
next();
|
|
});
|
|
|
|
router.use(RequestLoggerMiddleware);
|
|
|
|
for (const Title of VERSIONS) {
|
|
const inst = new Title();
|
|
|
|
const endpointSaltString = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[2];
|
|
const iterations = inst.numericVersion >= ChunithmVersions.SUN ? 70 : 44;
|
|
|
|
const key = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[0];
|
|
const iv = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[inst.version]?.[1];
|
|
|
|
if (
|
|
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
|
|
inst.numericVersion >= ChunithmVersions.CRYSTAL_PLUS &&
|
|
(!key || !iv)
|
|
) {
|
|
logger.warn(
|
|
`Disabling ${inst.gameCode} v${inst.version}, because ENCRYPTED_ONLY was enabled but no keys are provided.`,
|
|
{
|
|
gameCode: inst.gameCode,
|
|
version: inst.version,
|
|
}
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
|
|
inst.numericVersion >= ChunithmVersions.NEW &&
|
|
!endpointSaltString
|
|
) {
|
|
logger.warn(
|
|
`Disabling ${inst.gameCode} v${inst.version}, because ENCRYPTED_ONLY was enabled but no endpoint salt was provided.`,
|
|
{
|
|
gameCode: inst.gameCode,
|
|
version: inst.version,
|
|
}
|
|
);
|
|
}
|
|
|
|
const subrouter: Router = Router({ mergeParams: false });
|
|
const methods = GetClassMethods(Title).filter((m) => m.startsWith("handle_"));
|
|
const endpointSalt = endpointSaltString ? Buffer.from(endpointSaltString, "hex") : null;
|
|
|
|
for (const method of methods) {
|
|
const methodName = method.split("_")[1];
|
|
|
|
if (!methodName) {
|
|
logger.warn(
|
|
"Method starts with 'handle_', but cannot get the part after the underscore?",
|
|
{
|
|
method,
|
|
}
|
|
);
|
|
continue;
|
|
}
|
|
|
|
const impl = (inst as unknown as Record<string, unknown>)[method] as RequestHandler;
|
|
const implWrapper: RequestHandler = (req, res, next) => {
|
|
impl.apply(inst, [req, res, next]);
|
|
};
|
|
|
|
subrouter.post(`/${methodName}`, implWrapper);
|
|
|
|
if (endpointSalt) {
|
|
const hash = pbkdf2Sync(methodName, endpointSalt, iterations, 16, "sha1").toString(
|
|
"hex"
|
|
);
|
|
|
|
subrouter.post(`/${hash}`, implWrapper);
|
|
}
|
|
}
|
|
|
|
router.use(`/${inst.gameCode}/${inst.version}/${inst.servletName}`, subrouter);
|
|
|
|
// TODO: Add to a title registry
|
|
}
|
|
|
|
export default router;
|