chuni: error handling, response validation and utilities

This commit is contained in:
beerpsi 2023-11-25 14:22:42 +07:00
parent 176b2c3352
commit f019f6575e
5 changed files with 246 additions and 291 deletions

View File

@ -1,19 +1,19 @@
import { VERSIONS } from "./versions";
import { ChunkLength, ToBuffer } from "../utils/buffer";
import { GetClassMethods } from "../utils/reflection";
import compression from "compression";
import { TransformResponse } from "../utils/response";
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 { ZodError } from "zod";
import { fromZodError } from "zod-validation-error";
import { createCipheriv, createDecipheriv, pbkdf2Sync } from "crypto";
import { promisify } from "util";
import zlib, { createDeflate, inflate } from "zlib";
import type { RequestHandler } from "express";
import type { ErrorRequestHandler, RequestHandler } from "express";
import type { Transform } from "stream";
const logger = CreateLogCtx(__filename);
@ -30,11 +30,10 @@ router.use(async (req, res, next) => {
// /SDHD/210/UpsertUserAllApi
const splitPath = req.path.split("/");
const version = splitPath[2];
const versionNum = Number(version);
const version = Number(splitPath[2]);
const endpoint = splitPath.at(-1);
if (!endpoint || !version || Number.isNaN(versionNum)) {
if (!endpoint || Number.isNaN(version)) {
next();
return;
}
@ -44,7 +43,7 @@ router.use(async (req, res, next) => {
if (
!isEncryptedRequest &&
Config.CHUNITHM_CONFIG.CRYPTO.ENCRYPTED_ONLY &&
versionNum >= ChunithmVersions.CRYSTAL_PLUS
version >= ChunithmVersions.CRYSTAL_PLUS
) {
logger.error(
"Rejecting unencrypted request, because the server is configured to accept only encrypted requests.",
@ -54,194 +53,81 @@ router.use(async (req, res, next) => {
return res.status(200).send({ stat: "0" });
}
const body = await getRawBody(req, { limit: "20mb" });
const transformations: Array<Transform> = [
createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH }),
];
let decrypted: Buffer | null = null;
if (!isEncryptedRequest) {
const decompressed = await inflateAsync(body);
if (isEncryptedRequest) {
const keys = Config.CHUNITHM_CONFIG.CRYPTO.KEYS?.[version];
// 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 }
);
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 (version <= 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 body = await getRawBody(req, { limit: "20mb" });
res.locals.key = key;
res.locals.iv = iv;
decrypted = Buffer.concat([cipher.update(body), cipher.final()]);
transformations.push(createCipheriv("aes-256-cbc", key, iv));
} else {
decrypted = await getRawBody(req, { limit: "20mb" });
}
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 */
TransformResponse(res, ...transformations);
res.locals.transformationsSet = true;
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) => {
router.use((req, _, next) => {
if (Environment.nodeEnv === "dev" && req.header("x-debug") !== undefined) {
next();
return;
@ -324,8 +210,9 @@ for (const Title of VERSIONS) {
}
const impl = (inst as unknown as Record<string, unknown>)[method] as RequestHandler;
const implWrapper: RequestHandler = (req, res, next) => {
impl.apply(inst, [req, res, next]);
const implWrapper: RequestHandler = async (req, res, next) => {
// eslint-disable-next-line @typescript-eslint/no-confusing-void-expression
await Promise.resolve(impl.call(inst, req, res, next));
};
subrouter.post(`/${methodName}`, implWrapper);
@ -344,4 +231,30 @@ for (const Title of VERSIONS) {
// TODO: Add to a title registry
}
const ERR_HANDLER: ErrorRequestHandler = (err, req, res, _next) => {
// eslint-disable-next-line cadence/no-instanceof
const reportedErr = err instanceof ZodError ? fromZodError(err).message : err;
logger.error("Error propagated to title router.", {
err: reportedErr,
url: req.originalUrl,
});
if (!res.locals.transformationsSet) {
const transformations: Array<Transform> = [
createDeflate({ flush: zlib.constants.Z_SYNC_FLUSH }),
];
if (res.locals.key && res.locals.iv) {
transformations.push(createCipheriv("aes-256-cbc", res.locals.key, res.locals.iv));
}
TransformResponse(res, ...transformations);
}
return res.status(200).send({ returnCode: "0" });
};
router.use(ERR_HANDLER);
export default router;

View File

@ -0,0 +1,14 @@
import { z } from "zod";
export const UserRequest = z.object({
userId: z.coerce.number(),
});
export const UserPaginatedRequest = UserRequest.extend({
nextIndex: z.coerce.number(),
maxCount: z.coerce.number(),
});
export const UserRequestWithKind = UserRequest.extend({
kind: z.coerce.number(),
});

View File

@ -1,4 +1,5 @@
import { GetByCardIdAndVersion } from "../utils";
import { UserPaginatedRequest, UserRequest, UserRequestWithKind } from "../validators";
import { ChunithmUserCharacter, ChunithmUserItem } from "external/db/entity/chunithm/item";
import {
ChunithmUserActivity,
@ -13,7 +14,6 @@ import CreateLogCtx from "lib/logger/logger";
import { Config } from "lib/setup/config";
import { DateTime } from "luxon";
import { BaseTitle } from "servers/titles/types/titles";
import { ParseStrInt } from "servers/titles/utils/string-checks";
import { LessThanOrEqual } from "typeorm";
import type { Request, Response } from "express";
@ -141,15 +141,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserPreviewApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
if (!userId) {
logger.error("Invalid request: received userId is not a number.", {
userId: req.safeBody.userId,
});
return res.send({ returnCode: "1" });
}
const body = UserRequest.parse(req.safeBody);
const userId = body.userId;
const profile = await GetByCardIdAndVersion(ChunithmUserData, userId, this.numericVersion);
@ -217,15 +210,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserDataApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
if (!userId) {
logger.error("Invalid request: received userId is not a number.", {
userId: req.safeBody.userId,
});
return res.send({ returnCode: "1" });
}
const body = UserRequest.parse(req.safeBody);
const userId = body.userId;
const profile = await GetByCardIdAndVersion(ChunithmUserData, userId, this.numericVersion);
@ -244,15 +230,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserDataExApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
if (!userId) {
logger.error("Invalid request: received userId is not a number.", {
userId: req.safeBody.userId,
});
return res.send({ returnCode: "1" });
}
const body = UserRequest.parse(req.safeBody);
const userId = body.userId;
const profile = await GetByCardIdAndVersion(
ChunithmUserDataEx,
@ -275,15 +254,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserOptionApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
if (!userId) {
logger.error("Invalid request: received userId is not a number.", {
userId: req.safeBody.userId,
});
return res.send({ returnCode: "1" });
}
const body = UserRequest.parse(req.safeBody);
const userId = body.userId;
const options = await GetByCardIdAndVersion(
ChunithmUserOption,
@ -306,15 +278,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserOptionExApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
if (!userId) {
logger.error("Invalid request: received userId is not a number.", {
userId: req.safeBody.userId,
});
return res.send({ returnCode: "1" });
}
const body = UserRequest.parse(req.safeBody);
const userId = body.userId;
const options = await GetByCardIdAndVersion(
ChunithmUserOptionEx,
@ -337,19 +302,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserCharacterApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
const nextIndex = ParseStrInt(req.safeBody.nextIndex);
const maxCount = ParseStrInt(req.safeBody.maxCount);
if (!userId || !nextIndex || !maxCount) {
logger.error("Invalid request: received userId/nextIndex/maxCount is not a number.", {
userId: req.safeBody.userId,
nextIndex: req.safeBody.nextIndex,
maxCount: req.safeBody.maxCount,
});
return res.send({ returnCode: "1" });
}
const body = UserPaginatedRequest.parse(req.safeBody);
const { userId, nextIndex, maxCount } = body;
const [characters, characterCount] = await ChunithmUserCharacter.findAndCount({
where: {
@ -372,17 +326,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserActivityApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
const kind = ParseStrInt(req.safeBody.kind);
if (!userId || !kind) {
logger.error("Invalid request: received userId/kind is not a number.", {
userId: req.safeBody.userId,
kind: req.safeBody.kind,
});
return res.send({ returnCode: "1" });
}
const body = UserRequestWithKind.parse(req.safeBody);
const { userId, kind } = body;
const activities = await ChunithmUserActivity.find({
where: {
@ -406,19 +351,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserItemApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
const nextIndex = ParseStrInt(req.safeBody.nextIndex);
const maxCount = ParseStrInt(req.safeBody.maxCount);
if (!userId || !nextIndex || !maxCount) {
logger.error("Invalid request: received userId/nextIndex/maxCount is not a number.", {
userId: req.safeBody.userId,
nextIndex: req.safeBody.nextIndex,
maxCount: req.safeBody.maxCount,
});
return res.send({ returnCode: "1" });
}
const body = UserPaginatedRequest.parse(req.safeBody);
const { userId, nextIndex, maxCount } = body;
const itemKind = Math.trunc(nextIndex / 1e10);
const skip = nextIndex % 1e10;
@ -446,15 +380,8 @@ export class Chunithm extends BaseTitle {
}
async handle_GetUserRecentRatingApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
if (!userId) {
logger.error("Invalid request: received userId is not a number.", {
userId: req.safeBody.userId,
});
return res.send({ returnCode: "1" });
}
const body = UserRequest.parse(req.safeBody);
const userId = body.userId;
const recentRatings = await ChunithmUserRecentRating.find({
where: {

View File

@ -1,12 +1,9 @@
import { ChunithmAmazon } from "./130-amazon";
import { UserRequestWithKind } from "../validators";
import { ChunithmUserFavoriteItem } from "external/db/entity/chunithm/item";
import CreateLogCtx from "lib/logger/logger";
import { ParseStrInt } from "servers/titles/utils/string-checks";
import { LessThanOrEqual } from "typeorm";
import type { Request, Response } from "express";
const logger = CreateLogCtx(__filename);
export class ChunithmAmazonPlus extends ChunithmAmazon {
constructor(gameCode?: string, version?: string, servletName?: string) {
super(gameCode ?? "SDBT", version ?? "135", servletName ?? "ChuniServlet");
@ -34,17 +31,8 @@ export class ChunithmAmazonPlus extends ChunithmAmazon {
}
async handle_GetUserFavoriteItemApi(req: Request, res: Response) {
const userId = ParseStrInt(req.safeBody.userId);
const kind = ParseStrInt(req.safeBody.kind);
if (!userId || !kind) {
logger.error("Invalid request: received userId/kind is not a number.", {
userId: req.safeBody.userId,
kind: req.safeBody.kind,
});
return res.send({ returnCode: "1" });
}
const body = UserRequestWithKind.parse(req.safeBody);
const { userId, kind } = body;
const favorites = await ChunithmUserFavoriteItem.find({
where: {

View File

@ -0,0 +1,113 @@
import { ChunkLength, ToBuffer } from "./buffer";
import onHeaders from "on-headers";
import type { Response } from "express";
import type { Transform } from "stream";
/**
* Pipe the response through a series of transformations.
*
* Inspired by [node.js compression middleware](https://www.npmjs.com/package/compression).
* @param res The response to transform.
* @param transformers stream.Transform items.
*/
export function TransformResponse(res: Response, ...transformers: Array<Transform>) {
const head = transformers[0];
const tail = transformers.at(-1);
if (!head || !tail) {
throw new Error("At least one stream.Transform must be provided");
}
if (transformers.length > 1) {
for (let i = 0; i < transformers.length - 1; i++) {
const previous = transformers[i];
const next = transformers[i + 1];
if (!previous || !next) {
throw new Error("The provided transformers array have an undefined item.");
}
previous.pipe(next);
}
}
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) {
if (ended) {
return false;
}
// @ts-expect-error Typical JavaScript monkey patching.
if (!this._header && !this.headersSent) {
this.writeHead(this.statusCode);
}
const enc = typeof encoding === "function" ? undefined : encoding;
return head.write(ToBuffer(chunk, enc));
};
// @ts-expect-error More monkey patching shenanigans. It works, we'll live.
res.end = function end(chunk, encoding) {
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) {
// estimate the length
if (!this.getHeader("Content-Length")) {
this.setHeader("Content-Length", ChunkLength(chunk, enc));
}
this.writeHead(this.statusCode);
}
// mark ended
ended = true;
// write Buffer for Node.js 0.8
// @ts-expect-error bruh
return chunk ? head.end(ToBuffer(chunk, encoding)) : head.end();
};
// @ts-expect-error zzz
res.on = function on(event, listener) {
if (event !== "drain") {
return _on.call(this, event, listener);
}
return head.on(event, listener);
};
/* eslint-enable prefer-arrow-callback */
onHeaders(res, () => {
res.removeHeader("Content-Length");
tail.on("data", (chunk) => {
// @ts-expect-error ugggh i hate monkeys
if (!_write.call(res, chunk)) {
head.pause();
}
});
tail.on("end", () => {
// @ts-expect-error ugggh i hate monkeys
_end.call(res);
});
_on.call(res, "drain", () => {
head.resume();
});
});
}