348 lines
9.0 KiB
TypeScript
348 lines
9.0 KiB
TypeScript
import { VERSIONS } from "./versions";
|
|
import { ChunkLength, ToBuffer } from "../utils/buffer";
|
|
import { GetClassMethods } from "../utils/reflection";
|
|
import compression from "compression";
|
|
import { Router } 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 onHeaders from "on-headers";
|
|
import getRawBody from "raw-body";
|
|
import { IsHexadecimalString } from "utils/misc";
|
|
import { createCipheriv, createDecipheriv, pbkdf2Sync } from "crypto";
|
|
import { promisify } from "util";
|
|
import zlib, { createDeflate, inflate } from "zlib";
|
|
import type { RequestHandler } from "express";
|
|
import type { Transform } from "stream";
|
|
|
|
const logger = CreateLogCtx(__filename);
|
|
|
|
const inflateAsync = promisify(inflate);
|
|
|
|
const router: Router = Router({ mergeParams: false });
|
|
|
|
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 && IsHexadecimalString(endpoint);
|
|
|
|
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" });
|
|
}
|
|
|
|
const body = await getRawBody(req, { limit: "20mb" });
|
|
|
|
if (!isEncryptedRequest) {
|
|
const decompressed = await inflateAsync(body);
|
|
|
|
// eslint-disable-next-line require-atomic-updates
|
|
req.body = JSON.parse(decompressed.toString("utf-8"));
|
|
|
|
next();
|
|
return;
|
|
}
|
|
|
|
logger.debug("Received encrypted request.", { version, endpoint });
|
|
res.locals.encrypted = true;
|
|
|
|
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) {
|
|
logger.debug("Resolved to unhashed endpoint from User-Agent.", { 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 }
|
|
);
|
|
}
|
|
}
|
|
|
|
const keyString = keys[0];
|
|
const ivString = keys[1];
|
|
|
|
if (!keyString || !ivString) {
|
|
logger.error("Key or IV was not provided for the requested version.", {
|
|
version,
|
|
key: keyString,
|
|
iv: ivString,
|
|
});
|
|
|
|
return res.status(200).send({ stat: "0" });
|
|
}
|
|
|
|
const key = Buffer.from(keyString, "hex");
|
|
const iv = Buffer.from(ivString, "hex");
|
|
|
|
const cipher = createDecipheriv("aes-256-cbc", key, iv);
|
|
const decrypted = Buffer.concat([cipher.update(body), cipher.final()]);
|
|
const decompressed = await inflateAsync(decrypted);
|
|
|
|
// eslint-disable-next-line require-atomic-updates
|
|
req.body = JSON.parse(decompressed.toString("utf-8"));
|
|
|
|
// Set up response necryption
|
|
// Eldritch horrors beyond my understanding down here.
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const listeners: Array<[string | symbol, any]> = [];
|
|
let stream: Transform | null = null;
|
|
let ended = false;
|
|
|
|
/* eslint-disable @typescript-eslint/unbound-method */
|
|
const _on = res.on;
|
|
const _write = res.write;
|
|
const _end = res.end;
|
|
/* eslint-enable @typescript-eslint/unbound-method */
|
|
|
|
/* eslint-disable prefer-arrow-callback */
|
|
res.write = function write(chunk, encoding, ...rest) {
|
|
if (ended) {
|
|
return false;
|
|
}
|
|
|
|
// @ts-expect-error monkey noises
|
|
if (!this._header && !this.headersSent) {
|
|
this.writeHead(this.statusCode);
|
|
}
|
|
|
|
const enc = typeof encoding === "function" ? undefined : encoding;
|
|
|
|
return stream
|
|
? stream.write(ToBuffer(chunk, enc))
|
|
: // @ts-expect-error reeeee
|
|
_write.call(this, chunk, encoding, ...rest);
|
|
};
|
|
|
|
// @ts-expect-error idk
|
|
res.end = function end(chunk, encoding, ...rest) {
|
|
if (ended) {
|
|
return false;
|
|
}
|
|
|
|
const enc = typeof encoding === "function" ? undefined : encoding;
|
|
|
|
// @ts-expect-error how do express people live like this?
|
|
if (!this._header && !this.headersSent) {
|
|
this.writeHead(this.statusCode);
|
|
|
|
// estimate the length
|
|
if (!this.getHeader("Content-Length")) {
|
|
this.setHeader("Content-Length", ChunkLength(chunk, enc));
|
|
}
|
|
}
|
|
|
|
if (!stream) {
|
|
// @ts-expect-error bruh
|
|
return _end.call(this, chunk, encoding, ...rest);
|
|
}
|
|
|
|
// mark ended
|
|
ended = true;
|
|
|
|
// write Buffer for Node.js 0.8
|
|
// @ts-expect-error bruh
|
|
return chunk ? stream.end(ToBuffer(chunk, encoding)) : stream.end();
|
|
};
|
|
|
|
// @ts-expect-error zzz
|
|
res.on = function on(event, listener) {
|
|
if (event !== "drain") {
|
|
return _on.call(this, event, listener);
|
|
}
|
|
|
|
if (stream) {
|
|
return stream.on(event, listener);
|
|
}
|
|
|
|
// buffer listeners for future stream
|
|
listeners.push([event, listener]);
|
|
|
|
return this;
|
|
};
|
|
|
|
onHeaders(res, function onResponseHeaders() {
|
|
const cipher = createCipheriv("aes-256-cbc", key, iv);
|
|
|
|
stream = createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH });
|
|
stream.pipe(cipher);
|
|
|
|
for (const listener of listeners) {
|
|
stream.on(listener[0], listener[1]);
|
|
}
|
|
|
|
res.removeHeader("Content-Length");
|
|
|
|
cipher.on("data", (chunk) => {
|
|
// @ts-expect-error ugggh i hate monkeys
|
|
if (!_write.call(res, chunk)) {
|
|
stream?.pause();
|
|
}
|
|
});
|
|
|
|
cipher.on("end", () => {
|
|
// @ts-expect-error ugggh i hate monkeys
|
|
_end.call(res);
|
|
});
|
|
|
|
_on.call(res, "drain", () => {
|
|
stream?.resume();
|
|
});
|
|
});
|
|
/* eslint-enable prefer-arrow-callback */
|
|
|
|
next();
|
|
});
|
|
|
|
// you WILL compress the response and you WILL be happy about it.
|
|
// chunithm assumes all responses are deflated.
|
|
router.use(
|
|
compression({
|
|
threshold: 0,
|
|
filter: (req, res) => !res.locals.encrypted,
|
|
})
|
|
);
|
|
|
|
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();
|
|
});
|
|
|
|
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;
|