Merge pull request #391 from TNG-dev:zkldi/issue-390-KShook-Support

KsHook Support
This commit is contained in:
zkldi
2021-11-05 17:57:46 +00:00
committed by GitHub
55 changed files with 1120 additions and 237 deletions
+36 -1
View File
@@ -20,7 +20,6 @@
REDIRECT_URI: "https://example.com",
},
ARC_AUTH_TOKEN: "unused",
TYPE: "omni",
ENABLE_SERVER_HTTPS: false,
RUN_OWN_CDN: true,
CDN_WEB_LOCATION: "/cdn",
@@ -30,4 +29,40 @@
INVITE_CAP: 100,
BETA_USER_BONUS: 5,
},
TACHI_CONFIG: {
TYPE: "omni",
NAME: "Tachi Testing",
GAMES: [
"iidx",
"museca",
"maimai",
"sdvx",
"ddr",
"bms",
"chunithm",
"usc",
],
IMPORT_TYPES: [
"file/eamusement-iidx-csv",
"file/batch-manual",
"file/solid-state-squad",
"file/mer-iidx",
"file/pli-iidx-csv",
"ir/direct-manual",
"ir/barbatos",
"ir/fervidex",
"ir/fervidex-static",
"ir/beatoraja",
"ir/chunitachi",
"ir/usc",
"ir/kshook-sv3c",
"api/arc-iidx",
"api/arc-sdvx",
"api/eag-iidx",
"api/eag-sdvx",
"api/flo-iidx",
"api/flo-sdvx",
"api/min-sdvx",
],
},
}
+1 -1
View File
@@ -79,7 +79,7 @@
"redis": "3.1.2",
"rimraf": "3.0.2",
"safe-json-stringify": "1.2.0",
"tachi-common": "0.2.36",
"tachi-common": "0.2.38",
"ts-node": "10.0.0",
"typescript": "4.3.4",
"winston": "3.3.3",
+4 -4
View File
@@ -61,7 +61,7 @@ specifiers:
rimraf: 3.0.2
safe-json-stringify: 1.2.0
supertest: 6.1.3
tachi-common: 0.2.36
tachi-common: 0.2.38
tap: 15.0.9
ts-node: 10.0.0
tsconfig-paths: 3.10.1
@@ -104,7 +104,7 @@ dependencies:
redis: 3.1.2
rimraf: 3.0.2
safe-json-stringify: 1.2.0
tachi-common: 0.2.36
tachi-common: 0.2.38
ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b
typescript: 4.3.4
winston: 3.3.3
@@ -4260,8 +4260,8 @@ packages:
strip-ansi: 6.0.0
dev: true
/tachi-common/0.2.36:
resolution: {integrity: sha512-4/enfkwE8HA4x4vrbNMrhLx2j/8JGnEMwAO5b0zSM150chDZiK30vXOmDW1IHSqxoIRZVuIyRqP7aEyUbf7FKA==}
/tachi-common/0.2.38:
resolution: {integrity: sha512-hsiutwz/TKjYRcV6TsFDhpPHUalKDF2IVJGX5K9UuAAynU2K/wJ5qHG3xWH4pEjYz0B0Vx263HGY2johCEDAAA==}
dependencies:
monk: 7.3.4
transitivePeerDependencies:
@@ -1,12 +1,12 @@
import monk from "monk";
import { ServerConfig } from "lib/setup/config";
import { Environment, ServerConfig } from "lib/setup/config";
import CreateLogCtx from "lib/logger/logger";
const logger = CreateLogCtx(__filename);
logger.info(`Connecting to Old KTDB at ${ServerConfig.MONGO_CONNECTION_URL}/kamaitachidb.`);
logger.info(`Connecting to Old KTDB at ${Environment.mongoUrl}/kamaitachidb.`);
export const oldKTDB = monk(`${ServerConfig.MONGO_CONNECTION_URL}/kamaitachidb`);
export const oldKTDB = monk(`${Environment.mongoUrl}/kamaitachidb`);
oldKTDB
.then(() => {
@@ -2,7 +2,7 @@
import { UserGoalDocument } from "tachi-common";
import db from "external/mongo/db";
import CreateLogCtx from "../../src/common/logger";
import CreateLogCtx from "lib/logger/logger";
import MigrateRecords from "./migrate";
const logger = CreateLogCtx(__filename);
@@ -1,12 +1,12 @@
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
const logger = CreateLogCtx(__filename);
if (require.main === module) {
(async () => {
for (const game of ServerTypeInfo.supportedGames) {
for (const game of TachiConfig.GAMES) {
// eslint-disable-next-line no-await-in-loop
const res = await db.charts[game].update(
{ tierlistInfo: { $exists: false } },
@@ -6,7 +6,7 @@ import deepEqual from "deep-equal";
import { monkDB } from "external/mongo/db";
import fs from "fs";
import CreateLogCtx, { KtLogger } from "lib/logger/logger";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import { BulkWriteOperation } from "mongodb";
import { ICollection } from "monk";
import os from "os";
@@ -119,7 +119,7 @@ const syncInstructions: SyncInstructions[] = [
const bwriteOps: BulkWriteOperation<FolderDocument>[] = [];
for (const folder of folders) {
if (!ServerTypeInfo.supportedGames.includes(folder.game)) {
if (!TachiConfig.GAMES.includes(folder.game)) {
continue; // Skip things for games we don't care about.
}
@@ -162,7 +162,7 @@ const syncInstructions: SyncInstructions[] = [
const bwriteOps: BulkWriteOperation<TableDocument>[] = [];
for (const table of tables) {
if (!ServerTypeInfo.supportedGames.includes(table.game)) {
if (!TachiConfig.GAMES.includes(table.game)) {
continue; // Skip things for games we don't care about.
}
@@ -218,9 +218,9 @@ async function SynchroniseDBWithSeeds() {
if (collectionName.startsWith("songs-") || collectionName.startsWith("charts-")) {
const game = collectionName.split("-")[1];
if (!ServerTypeInfo.supportedGames.includes(game as any)) {
if (!TachiConfig.GAMES.includes(game as any)) {
spawnLogger.info(
`Skipping ${collectionName} (${game}) as it isn't for ${ServerTypeInfo.name}.`
`Skipping ${collectionName} (${game}) as it isn't for ${TachiConfig.NAME}.`
);
continue;
}
+2 -2
View File
@@ -2,7 +2,7 @@
import monk, { IMonkManager } from "monk";
import { IndexOptions } from "mongodb";
import CreateLogCtx from "lib/logger/logger";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import { ONE_DAY } from "lib/constants/time";
import { Databases } from "./db";
@@ -87,7 +87,7 @@ const staticIndexes: Partial<Record<Databases, Index[]>> = {
const indexes: Partial<Record<Databases, Index[]>> = staticIndexes;
for (const game of ServerTypeInfo.supportedGames) {
for (const game of TachiConfig.GAMES) {
if (indexes[`charts-${game}` as Databases]) {
indexes[`charts-${game}` as Databases]!.push(
index({ chartID: 1 }, UNIQUE),
+2 -2
View File
@@ -10,7 +10,7 @@ import { allIDStrings, allImportTypes } from "tachi-common/js/config/static-conf
import p, { PrudenceSchema } from "prudence";
import { Databases } from "./db";
import { AllPermissions } from "server/middleware/auth";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import { IsValidGame, IsValidPlaytype } from "utils/misc";
import { optNull } from "utils/prudence";
@@ -32,7 +32,7 @@ function prSchemaify(schema: PrudenceSchema) {
};
}
const games = ServerTypeInfo.supportedGames;
const games = TachiConfig.GAMES;
const isValidPlaytype = (self: unknown, parent: Record<string, unknown>) => {
if (!parent.game || typeof parent.game !== "string" || !IsValidGame(parent.game)) {
throw new Error(`Invalid Schema, need game to base IsValidPlaytype off of.`);
+2 -2
View File
@@ -1,6 +1,6 @@
import redis from "redis";
import CreateLogCtx from "lib/logger/logger";
import { ServerConfig } from "lib/setup/config";
import { ServerConfig, TachiConfig } from "lib/setup/config";
/**
* This code has been stubbed out! It doesn't really have a use at the moment.
@@ -33,7 +33,7 @@ const SubCallbacks: Partial<SubCallbacks> = {};
const SubClient = redis.createClient();
const PubClient = redis.createClient();
const PREFIX = ServerConfig.TYPE.toUpperCase(); // KTCHI or BTCHI
const PREFIX = TachiConfig.NAME.toUpperCase();
export function RedisPub<T extends RedisIPCChannels>(channel: T, data: RedisIPCData[T]) {
PubClient.publish(`${PREFIX}-${channel}`, JSON.stringify(data));
+2
View File
@@ -8,3 +8,5 @@ export const REV_2DXBMS = "Z";
export const REV_OMNIMIX = "X";
export const EXT_HEROIC_VERSE = "2020092900";
export const MODEL_SDVX3_KONASTE = "QCV";
+2 -2
View File
@@ -1,7 +1,7 @@
// Email HTML is a hellish mess of IE5 era nonsense.
// Good luck.
import { ServerConfig, ServerTypeInfo } from "lib/setup/config";
import { ServerConfig, TachiConfig, TachiServerConfig } from "lib/setup/config";
export function EmailFormatResetPassword(username: string, resetCode: string, ipAddr: string) {
return MainHTMLWrapper(
@@ -23,7 +23,7 @@ export function MainHTMLWrapper(innerHTML: string) {
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html charset=UTF-8" />
<title>${ServerTypeInfo.name}</title>
<title>${TachiConfig.NAME}</title>
</head>
<body>
${innerHTML}
@@ -29,7 +29,7 @@ import { DryScore } from "../common/types";
import { OrphanScore } from "../orphans/orphans";
/**
* Processes the iterable data into the Kamaitachi database.
* Processes the iterable data into the Tachi database.
* @param userID - The user this score import was for.
* @param iterableData - The data to iterate upon.
* @param ConverterFunction - The function needed to convert the data into an IntermediateScore
@@ -254,7 +254,7 @@ export async function ProcessSuccessfulConverterReturn(
}
/**
* Hydrates and inserts a score to the Kamaitachi database.
* Hydrates and inserts a score to the Tachi database.
* @param userID - The user this score is from.
* @param dryScore - The score that is to be hydrated and inserted.
* @param chart - The chart this score is on.
@@ -2,7 +2,7 @@ import { KtLogger } from "lib/logger/logger";
import ScoreImportFatalError from "../../../framework/score-importing/score-import-error";
import nodeFetch from "utils/fetch";
import { VERSION_STR } from "lib/constants/version";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
/**
* A Kai Reauth function is an async function that returns a string
@@ -70,7 +70,7 @@ export async function* TraverseKaiAPI(
res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
"User-Agent": `${ServerTypeInfo.name}/${VERSION_STR}`,
"User-Agent": `${TachiConfig.NAME}/${VERSION_STR}`,
"Content-Type": "application/json",
},
});
@@ -127,7 +127,7 @@ export async function* TraverseKaiAPI(
throw new ScoreImportFatalError(
500,
`Recieved no _links prop from ${url}. This is not an error with ${ServerTypeInfo.name}.`
`Recieved no _links prop from ${url}. This is not an error with ${TachiConfig.NAME}.`
);
}
@@ -14,7 +14,7 @@ import {
import deepmerge from "deepmerge";
import { FormatPrError } from "utils/prudence";
import { ParserFunctionReturns } from "../types";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
const optNull = (v: ValidSchemaValue) => p.optional(p.nullable(v));
@@ -172,7 +172,7 @@ const PR_BatchManualScore = (game: Game, playtype: Playtypes[Game]): PrudenceSch
const PR_BatchManual = (game: Game, playtype: Playtypes[Game]): PrudenceSchema => ({
meta: {
service: p.isBoundedString(3, 15),
game: p.isIn(ServerTypeInfo.supportedGames),
game: p.isIn(TachiConfig.GAMES),
playtype: p.is(playtype),
version: "*?string",
},
@@ -222,12 +222,10 @@ export function ParseBatchManualFromObject(
);
}
if (!ServerTypeInfo.supportedGames.includes(possiblyGame)) {
if (!TachiConfig.GAMES.includes(possiblyGame)) {
throw new ScoreImportFatalError(
400,
`Invalid game ${possiblyGame} - expected any of ${ServerTypeInfo.supportedGames.join(
", "
)}.`
`Invalid game ${possiblyGame} - expected any of ${TachiConfig.GAMES.join(", ")}.`
);
}
@@ -23,6 +23,7 @@ import { BeatorajaContext, BeatorajaScore } from "../ir/beatoraja/types";
import { USCClientScore } from "server/router/ir/usc/types";
import { IRUSCContext } from "../ir/usc/types";
import { ClassHandler } from "../../framework/user-game-stats/types";
import { KsHookSV3CScore } from "../ir/kshook-sv3c/types";
export interface ImportTypeDataMap {
"file/eamusement-iidx-csv": IIDXEamusementCSVData;
"file/batch-manual": BatchManualScore;
@@ -37,6 +38,7 @@ export interface ImportTypeDataMap {
"ir/chunitachi": BatchManualScore;
"ir/beatoraja": BeatorajaScore;
"ir/usc": USCClientScore;
"ir/kshook-sv3c": KsHookSV3CScore;
// These aren't placeholder values - the data is yielded in a way that
// the value of these is legitimately unknown at convert time.
@@ -63,6 +65,7 @@ export interface ImportTypeContextMap {
"ir/chunitachi": BatchManualContext;
"ir/beatoraja": BeatorajaContext;
"ir/usc": IRUSCContext;
"ir/kshook-sv3c": EmptyObject;
"api/arc-iidx": EmptyObject;
"api/arc-sdvx": EmptyObject;
@@ -12,6 +12,7 @@ import { ConverterIRBarbatos } from "./ir/barbatos/converter";
import { ConverterIRBeatoraja } from "./ir/beatoraja/converter";
import { ConverterIRFervidexStatic } from "./ir/fervidex-static/converter";
import { ConverterIRFervidex } from "./ir/fervidex/converter";
import { ConverterIRKsHookSV3C } from "./ir/kshook-sv3c/converter";
import { ConverterIRUSC } from "./ir/usc/converter";
export type ConverterMap = {
@@ -40,4 +41,5 @@ export const Converters: ConverterMap = {
"ir/direct-manual": ConverterBatchManual,
"ir/usc": ConverterIRUSC,
"ir/chunitachi": ConverterBatchManual,
"ir/kshook-sv3c": ConverterIRKsHookSV3C,
};
@@ -33,11 +33,6 @@ t.test("#ConverterIRBarbatos", (t) => {
playtype: "Single",
levelNum: 10,
level: "10",
flags: {
"IN BASE GAME": true,
OMNIMIX: false,
"N-1": false,
},
data: {
inGameID: 1,
},
@@ -12,7 +12,7 @@ import { BeatorajaContext, BeatorajaScore, BeatorajaChart } from "./types";
import { ChartDocument, SongDocument } from "tachi-common";
import { HandleOrphanQueue } from "lib/orphan-queue/orphan-queue";
import { Random20Hex } from "utils/misc";
import { ServerConfig, ServerTypeInfo } from "lib/setup/config";
import { ServerConfig, TachiConfig } from "lib/setup/config";
const LAMP_LOOKUP = {
NoPlay: "NO PLAY",
@@ -56,9 +56,7 @@ export const ConverterIRBeatoraja: ConverterFunction<BeatorajaScore, BeatorajaCo
// check, remember to change the entire score import
// framework and database to be able to handle variable notecounts.
logger.verbose(`Declined to orphan chart ${chartName} as it has #RANDOM declarations.`);
throw new InvalidScoreFailure(
`${ServerTypeInfo.name} will not support #RANDOM charts.`
);
throw new InvalidScoreFailure(`${TachiConfig.NAME} will not support #RANDOM charts.`);
}
const idString = context.chart.mode === "BEAT_7K" ? "bms:7K" : "bms:14K";
@@ -0,0 +1,71 @@
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { TestingKsHookSV3CScore } from "test-utils/test-data";
import { ConverterIRKsHookSV3C } from "./converter";
const logger = CreateLogCtx(__filename);
t.test("#ConverterIRKsHookSV3C", (t) => {
t.beforeEach(ResetDBState);
t.test("Should match a score with its song and chart.", async (t) => {
const res = await ConverterIRKsHookSV3C(
TestingKsHookSV3CScore,
{},
"ir/kshook-sv3c",
logger
);
t.hasStrict(res, {
song: {
id: 1,
},
chart: {
data: {
inGameID: 1,
},
difficulty: "ADV",
},
dryScore: {
scoreData: {
score: 9_579_365,
grade: "AA+",
lamp: "EXCESSIVE CLEAR",
judgements: {
critical: 1184,
near: 46,
miss: 30,
},
hitMeta: {
slow: 10,
fast: 36,
maxCombo: 158,
},
},
game: "sdvx",
importType: "ir/kshook-sv3c",
scoreMeta: {},
},
});
t.end();
});
t.test("Should throw an error if song or chart can't be found.", (t) => {
t.rejects(
() =>
ConverterIRKsHookSV3C(
Object.assign({}, TestingKsHookSV3CScore, { music_id: 10000 }),
{},
"ir/kshook-sv3c",
logger
),
"Should throw a KTDataNotFoundError if chart can't be found."
);
t.end();
});
t.end();
});
@@ -0,0 +1,99 @@
import { FindSDVXChartOnInGameIDVersion } from "utils/queries/charts";
import { EmptyObject } from "utils/types";
import { ConverterFunction } from "../../common/types";
import { KsHookSV3CScore } from "./types";
import { Lamps } from "tachi-common";
import {
InternalFailure,
KTDataNotFoundFailure,
} from "lib/score-import/framework/common/converter-failures";
import { FindSongOnID } from "utils/queries/songs";
import { GenericGetGradeAndPercent } from "lib/score-import/framework/common/score-utils";
import { DryScore } from "lib/score-import/framework/common/types";
export const ConverterIRKsHookSV3C: ConverterFunction<KsHookSV3CScore, EmptyObject> = async (
data,
context,
importType,
logger
) => {
const diff = ConvertDifficulty(data.difficulty);
const chart = await FindSDVXChartOnInGameIDVersion(data.music_id, diff, "konaste");
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${data.music_id} (${diff} for Konaste).`,
importType,
data,
context
);
}
const song = await FindSongOnID("sdvx", chart.songID);
if (!song) {
logger.severe(`Song ${chart.songID} (sdvx) has no parent song?`);
throw new InternalFailure(`Song ${chart.songID} (sdvx) has no parent song?`);
}
const { percent, grade } = GenericGetGradeAndPercent("sdvx", data.score, chart);
const dryScore: DryScore<"sdvx:Single"> = {
game: "sdvx",
service: "kshook SV3C",
comment: null,
importType: "ir/kshook-sv3c",
timeAchieved: Date.now(),
scoreData: {
score: data.score,
percent,
grade,
lamp: ConvertLamp(data.clear),
judgements: {
critical: data.critical,
near: data.near,
miss: data.error,
},
hitMeta: {
fast: data.early,
slow: data.late,
gauge: data.gauge,
maxCombo: data.max_chain,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
};
function ConvertLamp(clear: KsHookSV3CScore["clear"]): Lamps["sdvx:Single"] {
if (clear === "CLEAR_PLAYED") {
return "FAILED";
} else if (clear === "CLEAR_EFFECTIVE") {
return "CLEAR";
} else if (clear === "CLEAR_EXCESSIVE") {
return "EXCESSIVE CLEAR";
} else if (clear === "CLEAR_ULTIMATE_CHAIN") {
return "ULTIMATE CHAIN";
}
return "PERFECT ULTIMATE CHAIN";
}
function ConvertDifficulty(
diff: KsHookSV3CScore["difficulty"]
): "NOV" | "ADV" | "EXH" | "ANY_INF" | "MXM" {
if (diff === "DIFFICULTY_NOVICE") {
return "NOV";
} else if (diff === "DIFFICULTY_ADVANCED") {
return "ADV";
} else if (diff === "DIFFICULTY_EXHAUST") {
return "EXH";
} else if (diff === "DIFFICULTY_INFINITE") {
return "ANY_INF";
}
return "MXM";
}
@@ -0,0 +1,102 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { SDVXDans } from "lib/constants/classes";
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import { TestingKsHookSV3CScore } from "test-utils/test-data";
import { ConvertSkillLevel, ParseKsHookSV3C } from "./parser";
import deepmerge from "deepmerge";
const logger = CreateLogCtx(__filename);
t.test("#ConvertSkillLevel", (t) => {
t.equal(ConvertSkillLevel("SKILL_LEVEL_01"), SDVXDans.DAN_1);
t.equal(ConvertSkillLevel("SKILL_LEVEL_02"), SDVXDans.DAN_2);
t.equal(ConvertSkillLevel("SKILL_LEVEL_03"), SDVXDans.DAN_3);
t.equal(ConvertSkillLevel("SKILL_LEVEL_04"), SDVXDans.DAN_4);
t.equal(ConvertSkillLevel("SKILL_LEVEL_05"), SDVXDans.DAN_5);
t.equal(ConvertSkillLevel("SKILL_LEVEL_06"), SDVXDans.DAN_6);
t.equal(ConvertSkillLevel("SKILL_LEVEL_07"), SDVXDans.DAN_7);
t.equal(ConvertSkillLevel("SKILL_LEVEL_08"), SDVXDans.DAN_8);
t.equal(ConvertSkillLevel("SKILL_LEVEL_09"), SDVXDans.DAN_9);
t.equal(ConvertSkillLevel("SKILL_LEVEL_10"), SDVXDans.DAN_10);
t.equal(ConvertSkillLevel("SKILL_LEVEL_11"), SDVXDans.DAN_11);
t.equal(ConvertSkillLevel("SKILL_LEVEL_12"), SDVXDans.INF);
t.equal(ConvertSkillLevel("SKILL_LEVEL_NONE"), null);
t.end();
});
t.test("#ParseKsHookSV3C", (t) => {
const assertFail = (data: any, message: string) => {
t.throws(() => ParseKsHookSV3C(data, logger), message);
};
const assertSuccess = (data: any, message: string) => {
try {
t.doesNotThrow(() => ParseKsHookSV3C(data, logger), message);
const res = ParseKsHookSV3C(data, logger);
t.equal(res.game, "sdvx");
t.strictSame(res.context, {});
t.ok(Array.isArray(res.iterable));
t.type(res.classHandler, "function");
} catch (err) {
t.fail(`[${message}] ${err.message}`);
}
};
const dm = (data: any) => deepmerge(TestingKsHookSV3CScore, data);
assertSuccess(TestingKsHookSV3CScore, "Should parse a valid score.");
assertSuccess(
dm({ unexpectedField: "foo" }),
"Should allow excess keys that we do not recognise."
);
assertFail({}, "Should reject an empty object");
assertFail(dm({ skill_level: "invalid_skill_level" }), "Should reject invalid skill levels.");
assertFail(dm({ appeal_id: 0.5 }), "Should reject non integer appeal_id.");
assertFail(dm({ appeal_id: -1 }), "Should reject negative integer appeal_id.");
assertFail(dm({ clear: "invalid_clear" }), "Should reject invalid clears.");
assertFail(dm({ difficulty: "invalid_difficulty" }), "Should reject invalid difficulties.");
assertFail(dm({ early: -1 }), "Should reject negative early counts.");
assertFail(dm({ late: -1 }), "Should reject negative late counts.");
assertFail(dm({ early: 1.5 }), "Should reject non-integer early counts.");
assertFail(dm({ late: 1.5 }), "Should reject non-integer late counts.");
assertFail(dm({ gauge: -1 }), "Should reject negative gauge values.");
assertFail(dm({ gauge: 101 }), "Should reject gauge values over 100.");
assertSuccess(dm({ gauge: 0 }), "Should allow gauge values of 0.");
assertSuccess(dm({ gauge: 100 }), "Should allow gauge values of 100.");
assertFail(dm({ grade: "invalid_grade" }), "Should reject invalid grades.");
assertFail(dm({ max_chain: -1 }), "Should reject negative max_chains.");
assertFail(dm({ max_chain: 100.5 }), "Should reject non-integer max_chains.");
assertFail(dm({ btn_rate: -1 }), "Should reject negative btn_rates.");
assertFail(dm({ btn_rate: 200.1 }), "Should reject btn_rates over 200.");
assertSuccess(dm({ btn_rate: 0 }), "Should allow btn_rate values of 0.");
assertSuccess(dm({ btn_rate: 200 }), "Should allow btn_rate values of 200.");
assertFail(dm({ vol_rate: -1 }), "Should reject negative vol_rates.");
assertFail(dm({ vol_rate: 200.1 }), "Should reject vol_rates over 200.");
assertSuccess(dm({ vol_rate: 0 }), "Should allow vol_rate values of 0.");
assertSuccess(dm({ vol_rate: 200 }), "Should allow vol_rate values of 200.");
assertFail(dm({ long_rate: -1 }), "Should reject negative long_rates.");
assertFail(dm({ long_rate: 200.1 }), "Should reject long_rates over 200.");
assertSuccess(dm({ long_rate: 0 }), "Should allow long_rate values of 0.");
assertSuccess(dm({ long_rate: 200 }), "Should allow long_rate values of 200.");
assertFail(dm({ player_name: null }), "Should reject non-string player_names.");
assertFail(dm({ rate: "invalid_rate" }), "Should reject invalid rates.");
assertFail(dm({ skill_frame: "invalid_skill_frame" }), "Should reject invalid skill_frames.");
assertFail(dm({ skill_level: "invalid_skill_level" }), "Should reject invalid skill_levels.");
assertFail(dm({ track_no: -1 }), "Should reject negative track_no's.");
assertFail(dm({ track_no: 50.5 }), "Should reject non-int track_no's.");
t.end();
});
@@ -0,0 +1,136 @@
import { SDVXDans } from "lib/constants/classes";
import { KtLogger } from "lib/logger/logger";
import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error";
import p, { PrudenceSchema } from "prudence";
import { FormatPrError } from "utils/prudence";
import { EmptyObject } from "utils/types";
import { ParserFunctionReturns } from "../../common/types";
import { KsHookSV3CScore } from "./types";
const PR_KsHookSV3C: PrudenceSchema = {
appeal_id: p.isPositiveInteger,
clear: p.isIn(
"CLEAR_PLAYED",
"CLEAR_EFFECTIVE",
"CLEAR_EXCESSIVE",
"CLEAR_ULTIMATE_CHAIN",
"CLEAR_PERFECT"
),
difficulty: p.isIn(
"DIFFICULTY_NOVICE",
"DIFFICULTY_ADVANCED",
"DIFFICULTY_EXHAUST",
"DIFFICULTY_INFINITE",
"DIFFICULTY_MAXIMUM"
),
early: p.isPositiveInteger,
late: p.isPositiveInteger,
gauge: p.isBetween(0, 100),
grade: p.isIn(
"GRADE_D",
"GRADE_C",
"GRADE_B",
"GRADE_A",
"GRADE_A_PLUS",
"GRADE_AA",
"GRADE_AA_PLUS",
"GRADE_AAA",
"GRADE_AAA_PLUS",
"GRADE_S"
),
max_chain: p.isPositiveInteger,
music_id: p.isPositiveInteger,
btn_rate: p.isBetween(0, 200),
long_rate: p.isBetween(0, 200),
vol_rate: p.isBetween(0, 200),
player_name: "string",
rate: p.isIn("RATE_EFFECTIVE", "RATE_PERMISSIVE", "RATE_EXCESSIVE", "RATE_BLASTIVE"),
skill_frame: p.isIn(
"SKILL_FRAME_NONE",
"SKILL_FRAME_SILVER",
"SKILL_FRAME_GOLD",
"SKILL_FRAME_GOLD_HALO"
),
skill_level: p.isIn(
"SKILL_LEVEL_NONE",
"SKILL_LEVEL_01",
"SKILL_LEVEL_02",
"SKILL_LEVEL_03",
"SKILL_LEVEL_04",
"SKILL_LEVEL_05",
"SKILL_LEVEL_06",
"SKILL_LEVEL_07",
"SKILL_LEVEL_08",
"SKILL_LEVEL_09",
"SKILL_LEVEL_10",
"SKILL_LEVEL_11",
"SKILL_LEVEL_12"
),
track_no: p.isPositiveInteger,
};
export function ParseKsHookSV3C(
body: Record<string, unknown>,
logger: KtLogger
): ParserFunctionReturns<KsHookSV3CScore, EmptyObject> {
// Ignore excess keys, as SV3C might add more features in the future.
const err = p(body, PR_KsHookSV3C, undefined, { allowExcessKeys: true });
if (err) {
throw new ScoreImportFatalError(400, FormatPrError(err));
}
const score = body as unknown as KsHookSV3CScore;
return {
game: "sdvx",
iterable: [score],
context: {},
classHandler: () => {
const skillLevel = ConvertSkillLevel(score.skill_level);
// If null, the player doesn't have a dan.
if (!skillLevel) {
return;
}
return {
dan: skillLevel,
};
},
};
}
export function ConvertSkillLevel(skill: KsHookSV3CScore["skill_level"]): SDVXDans | null {
switch (skill) {
case "SKILL_LEVEL_01":
return SDVXDans.DAN_1;
case "SKILL_LEVEL_02":
return SDVXDans.DAN_2;
case "SKILL_LEVEL_03":
return SDVXDans.DAN_3;
case "SKILL_LEVEL_04":
return SDVXDans.DAN_4;
case "SKILL_LEVEL_05":
return SDVXDans.DAN_5;
case "SKILL_LEVEL_06":
return SDVXDans.DAN_6;
case "SKILL_LEVEL_07":
return SDVXDans.DAN_7;
case "SKILL_LEVEL_08":
return SDVXDans.DAN_8;
case "SKILL_LEVEL_09":
return SDVXDans.DAN_9;
case "SKILL_LEVEL_10":
return SDVXDans.DAN_10;
case "SKILL_LEVEL_11":
return SDVXDans.DAN_11;
case "SKILL_LEVEL_12":
return SDVXDans.INF;
default:
return null;
}
}
@@ -0,0 +1,58 @@
import { integer } from "tachi-common";
export interface KsHookSV3CScore {
appeal_id: integer;
clear:
| "CLEAR_PLAYED"
| "CLEAR_EFFECTIVE"
| "CLEAR_EXCESSIVE"
| "CLEAR_ULTIMATE_CHAIN"
| "CLEAR_PERFECT";
difficulty: `DIFFICULTY_${"NOVICE" | "ADVANCED" | "EXHAUST" | "INFINITE" | "MAXIMUM"}`;
early: integer;
gauge: integer;
grade: `GRADE_${
| "D"
| "C"
| "B"
| "A"
| "A_PLUS"
| "AA"
| "AA_PLUS"
| "AAA"
| "AAA_PLUS"
| "S"}`;
late: integer;
max_chain: integer;
music_id: integer;
btn_rate: integer;
long_rate: integer;
vol_rate: integer;
critical: integer;
near: integer;
error: integer;
player_name: string;
rate: `RATE_${"EFFECTIVE" | "PERMISSIVE" | "EXCESSIVE" | "BLASTIVE"}`;
score: integer;
skill_frame: `SKILL_FRAME_${"NONE" | "SILVER" | "GOLD" | "GOLD_HALO"}`;
skill_level: `SKILL_LEVEL_${
| "NONE"
| "01"
| "02"
| "03"
| "04"
| "05"
| "06"
| "07"
| "08"
| "09"
| "10"
| "11"
| "12"}`;
skill_name: integer;
track_no: integer;
}
+2 -2
View File
@@ -14,7 +14,7 @@ import {
} from "tachi-common";
import { EscapeStringRegexp } from "utils/misc";
import { GetOnlineCutoff } from "utils/user";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
const logger = CreateLogCtx(__filename);
@@ -164,7 +164,7 @@ async function SearchAllGamesSingleGame(game: Game, search: string) {
export async function SearchAllGamesSongs(search: string) {
const promises = [];
for (const game of ServerTypeInfo.supportedGames) {
for (const game of TachiConfig.GAMES) {
promises.push(SearchAllGamesSingleGame(game, search));
}
+21 -20
View File
@@ -4,7 +4,7 @@ import fs from "fs";
import JSON5 from "json5";
import { SendMailOptions } from "nodemailer";
import p from "prudence";
import { integer, StaticConfig } from "tachi-common";
import { integer, StaticConfig, Game, ImportTypes } from "tachi-common";
import { FormatPrError } from "utils/prudence";
dotenv.config(); // imports things like NODE_ENV from a local .env file if one is present.
@@ -45,7 +45,7 @@ export interface OAuth2Info {
REDIRECT_URI: string;
}
export interface TachiConfig {
export interface TachiServerConfig {
MONGO_DATABASE_NAME: string;
LOG_LEVEL: "debug" | "verbose" | "info" | "warn" | "error" | "severe" | "crit";
CAPTCHA_SECRET_KEY: string;
@@ -58,11 +58,9 @@ export interface TachiConfig {
EAG_OAUTH2_INFO?: OAuth2Info;
MIN_OAUTH2_INFO?: OAuth2Info;
ARC_AUTH_TOKEN?: string;
TYPE: "ktchi" | "btchi" | "omni";
ENABLE_SERVER_HTTPS?: boolean;
RUN_OWN_CDN?: boolean;
CLIENT_DEV_SERVER?: string | null;
SERVER_TYPE_INFO: StaticConfig.ServerConfig;
RATE_LIMIT: integer;
OAUTH_CLIENT_CAP: integer;
OPTIONS_ALWAYS_SUCCEEDS?: boolean;
@@ -88,6 +86,12 @@ export interface TachiConfig {
INVITE_CAP: integer;
BETA_USER_BONUS: integer;
};
TACHI_CONFIG: {
NAME: string;
TYPE: "ktchi" | "btchi" | "omni";
GAMES: Game[];
IMPORT_TYPES: ImportTypes[];
};
}
const isValidOauth2 = p.optional({
@@ -112,7 +116,6 @@ const err = p(config, {
ENABLE_SERVER_HTTPS: "*boolean",
RUN_OWN_CDN: "*boolean",
CLIENT_DEV_SERVER: "*?string",
TYPE: p.isIn("ktchi", "btchi", "omni"),
RATE_LIMIT: p.optional(p.isPositiveInteger),
OAUTH_CLIENT_CAP: p.optional(p.isPositiveInteger),
OPTIONS_ALWAYS_SUCCEEDS: "*boolean",
@@ -136,30 +139,28 @@ const err = p(config, {
INVITE_CAP: p.isPositiveInteger,
BETA_USER_BONUS: p.isPositiveInteger,
}),
TACHI_CONFIG: {
NAME: "string",
TYPE: p.isIn("ktchi", "btchi", "omni"),
GAMES: [p.isIn(StaticConfig.allSupportedGames)],
IMPORT_TYPES: [p.isIn(StaticConfig.allImportTypes)],
},
});
if (err) {
throw FormatPrError(err, "Invalid conf.json5 file.");
}
if (config.TYPE === "ktchi") {
config.SERVER_TYPE_INFO = StaticConfig.KTCHI_CONFIG;
} else if (config.TYPE === "btchi") {
config.SERVER_TYPE_INFO = StaticConfig.BTCHI_CONFIG;
} else if (config.TYPE === "omni") {
config.SERVER_TYPE_INFO = StaticConfig.OMNI_CONFIG;
}
const tachiConfig = config as TachiConfig;
const tachiServerConfig = config as TachiServerConfig;
// default rate limit 500
tachiConfig.RATE_LIMIT ??= 500;
tachiConfig.OAUTH_CLIENT_CAP ??= 15;
tachiConfig.USC_QUEUE_SIZE ??= 3;
tachiConfig.BEATORAJA_QUEUE_SIZE ??= 3;
tachiServerConfig.RATE_LIMIT ??= 500;
tachiServerConfig.OAUTH_CLIENT_CAP ??= 15;
tachiServerConfig.USC_QUEUE_SIZE ??= 3;
tachiServerConfig.BEATORAJA_QUEUE_SIZE ??= 3;
export const ServerTypeInfo = tachiConfig.SERVER_TYPE_INFO;
export const ServerConfig = tachiConfig;
export const TachiConfig = tachiServerConfig.TACHI_CONFIG;
export const ServerConfig = tachiServerConfig;
// Environment Variable Validation
+2 -2
View File
@@ -5,7 +5,7 @@ import { InitSequenceDocs } from "external/mongo/sequence-docs";
import fs from "fs";
import https from "https";
import CreateLogCtx from "lib/logger/logger";
import { Environment, ServerConfig, ServerTypeInfo } from "lib/setup/config";
import { Environment, ServerConfig, TachiConfig, TachiServerConfig } from "lib/setup/config";
import path from "path";
import server from "server/server";
import { InitaliseFolderChartLookup } from "utils/folder";
@@ -13,7 +13,7 @@ import { FormatVersion } from "./lib/constants/version";
const logger = CreateLogCtx(__filename);
logger.info(`Booting ${ServerTypeInfo.name} - ${FormatVersion()} [ENV: ${Environment.nodeEnv}]`);
logger.info(`Booting ${TachiConfig.NAME} - ${FormatVersion()} [ENV: ${Environment.nodeEnv}]`);
logger.info(`Log level is set to ${ServerConfig.LOG_LEVEL}.`);
logger.info(`Loading sequence documents...`);
+87 -67
View File
@@ -22,6 +22,8 @@ export const AllPermissions: Record<APIPermissions, true> = {
delete_score: true,
};
export const SetRequestPermissions: RequestHandler = CreateSetRequestPermissions("description");
/**
* Sets the permissions for this request, alongside the user that is making the request.
*
@@ -36,61 +38,73 @@ export const AllPermissions: Record<APIPermissions, true> = {
*
* This is set on req[SYMBOL_TachiAPIAuth].
*/
export const SetRequestPermissions: RequestHandler = async (req, res, next) => {
if (req.session?.tachi?.user.id) {
function CreateSetRequestPermissions(errorKeyName: string): RequestHandler {
return async (req, res, next) => {
if (req.session?.tachi?.user.id) {
req[SYMBOL_TachiAPIAuth] = {
userID: req.session.tachi.user.id,
identifier: `Session-Key ${req.session.tachi.user.id}`,
token: null,
permissions: AllPermissions,
};
return next();
}
const header = req.header("Authorization");
// if no auth was attempted, default to the guest token.
if (!header) {
req[SYMBOL_TachiAPIAuth] = GuestToken;
return next();
}
const { token, type } = SplitAuthorizationHeader(header);
if (type !== "Bearer") {
return res.status(400).json({
success: false,
[errorKeyName]: "Invalid Authorization Type - Expected Bearer.",
});
}
if (!token) {
return res.status(401).json({
success: false,
[errorKeyName]: "Invalid token.",
});
}
const apiTokenData = await db["api-tokens"].findOne({
token,
});
if (!apiTokenData) {
return res.status(401).json({
success: false,
[errorKeyName]:
"The provided API token does not correspond with any key in the database.",
});
}
req[SYMBOL_TachiAPIAuth] = {
userID: req.session.tachi.user.id,
identifier: `Session-Key ${req.session.tachi.user.id}`,
token: null,
permissions: AllPermissions,
userID: apiTokenData.userID,
token,
permissions: apiTokenData.permissions,
identifier: apiTokenData.identifier,
};
return next();
}
const header = req.header("Authorization");
// if no auth was attempted, default to the guest token.
if (!header) {
req[SYMBOL_TachiAPIAuth] = GuestToken;
return next();
}
const { token, type } = SplitAuthorizationHeader(header);
if (type !== "Bearer") {
return res.status(400).json({
success: false,
description: "Invalid Authorization Type - Expected Bearer.",
});
}
if (!token) {
return res.status(401).json({
success: false,
description: "Invalid token.",
});
}
const apiTokenData = await db["api-tokens"].findOne({
token,
});
if (!apiTokenData) {
return res.status(401).json({
success: false,
description: "The provided API token does not correspond with any key in the database.",
});
}
req[SYMBOL_TachiAPIAuth] = {
userID: apiTokenData.userID,
token,
permissions: apiTokenData.permissions,
identifier: apiTokenData.identifier,
};
}
return next();
};
/**
* An identical implementation of SetRequestPermissions, but returns
* fervidex-style errors (error, instead of description).
*
* @see SetRequestPermissions
*/
export const SetFervidexStyleRequestPermissions: RequestHandler =
CreateSetRequestPermissions("error");
/**
* Returns a middleware that enforces the request has the necessary permissions.
@@ -144,22 +158,28 @@ export const RequirePermissions =
return next();
};
export const RequireNotGuest: RequestHandler = (req, res, next) => {
if (!req[SYMBOL_TachiAPIAuth]) {
logger.error(`RequirePermissions middleware was hit without any TachiAPIData?`);
return res.status(500).json({
success: false,
description: "An internal error has occured.",
});
}
const CreateRequireNotGuest =
(errorKeyName: string): RequestHandler =>
(req, res, next) => {
if (!req[SYMBOL_TachiAPIAuth]) {
logger.error(`RequirePermissions middleware was hit without any TachiAPIData?`);
return res.status(500).json({
success: false,
description: "An internal error has occured.",
});
}
if (!req[SYMBOL_TachiAPIAuth].userID) {
logger.info(`Request to ${req.method} ${req.url} was attempted by guest.`);
return res.status(401).json({
success: false,
description: "This endpoint requires authentication.",
});
}
if (req[SYMBOL_TachiAPIAuth].userID === null) {
logger.info(`Request to ${req.method} ${req.url} was attempted by guest.`);
return res.status(401).json({
success: false,
[errorKeyName]: "This endpoint requires authentication.",
});
}
return next();
};
return next();
};
export const RequireNotGuest: RequestHandler = CreateRequireNotGuest("description");
export const FervidexStyleRequireNotGuest: RequestHandler = CreateRequireNotGuest("error");
+2 -2
View File
@@ -1,5 +1,5 @@
import RateLimitRedis from "rate-limit-redis";
import { Environment, ServerConfig } from "lib/setup/config";
import { Environment, ServerConfig, TachiConfig } from "lib/setup/config";
import rateLimit from "express-rate-limit";
import CreateLogCtx from "lib/logger/logger";
import { RedisClient } from "external/redis/redis";
@@ -8,7 +8,7 @@ const logger = CreateLogCtx(__filename);
const store =
Environment.nodeEnv === "production" || Environment.nodeEnv === "staging"
? new RateLimitRedis({ prefix: `${ServerConfig.TYPE}-RL:`, client: RedisClient })
? new RateLimitRedis({ prefix: `${TachiConfig.NAME}-RL:`, client: RedisClient })
: undefined; // undefined forces a default to an in-memory store
export function ClearTestingRateLimitCache() {
+3 -3
View File
@@ -1,4 +1,4 @@
import { ServerConfig } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import { RequestHandler } from "express";
/**
@@ -7,7 +7,7 @@ import { RequestHandler } from "express";
* this restriction is bypassed.
*/
export const RequireBokutachi: RequestHandler = (req, res, next) => {
if (ServerConfig.TYPE === "btchi" || ServerConfig.TYPE === "omni") {
if (TachiConfig.TYPE === "btchi" || TachiConfig.TYPE === "omni") {
return next();
}
@@ -23,7 +23,7 @@ export const RequireBokutachi: RequestHandler = (req, res, next) => {
* this restriction is bypassed.
*/
export const RequireKamaitachi: RequestHandler = (req, res, next) => {
if (ServerConfig.TYPE === "ktchi" || ServerConfig.TYPE === "omni") {
if (TachiConfig.TYPE === "ktchi" || TachiConfig.TYPE === "omni") {
return next();
}
@@ -6,7 +6,7 @@ import CreateLogCtx, { ChangeRootLogLevel, GetLogLevel } from "lib/logger/logger
import prValidate from "server/middleware/prudence-validate";
import { GetUserWithID } from "utils/user";
import { ONE_MINUTE } from "lib/constants/time";
import { ServerConfig, ServerTypeInfo } from "lib/setup/config";
import { ServerConfig, TachiConfig } from "lib/setup/config";
import { Game, UserAuthLevels } from "tachi-common";
import db from "external/mongo/db";
@@ -115,7 +115,7 @@ router.post(
"/deprimarify",
prValidate({
chartID: "*string",
game: p.isIn(ServerTypeInfo.supportedGames),
game: p.isIn(TachiConfig.GAMES),
songID: p.optional(p.isPositiveNonZeroInteger),
}),
async (req, res) => {
@@ -26,7 +26,7 @@ import { SendEmail } from "lib/email/client";
import { EmailFormatResetPassword, EmailFormatVerifyEmail } from "lib/email/formats";
import { Random20Hex } from "utils/misc";
import { Environment, ServerConfig } from "lib/setup/config";
import { integer } from ".pnpm/tachi-common@0.2.36/node_modules/tachi-common";
import { integer } from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -1,5 +1,5 @@
import { RequestHandler } from "express";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import { IsValidGame } from "utils/misc";
import { AssignToReqTachiData } from "utils/req-tachi-data";
@@ -9,7 +9,7 @@ export const ValidateGameFromParam: RequestHandler = (req, res, next) => {
success: false,
description: `Invalid/unsupported game ${
req.params.game
} - Expected any of ${ServerTypeInfo.supportedGames.join(", ")}`,
} - Expected any of ${TachiConfig.GAMES.join(", ")}`,
});
}
@@ -1,6 +1,6 @@
import { GetGameConfig } from "tachi-common";
import t from "tap";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import mockApi from "test-utils/mock-api";
@@ -9,10 +9,10 @@ t.test("GET /api/v1/games", async (t) => {
// and also returns configs properly.
const res = await mockApi.get("/api/v1/games");
t.strictSame(res.body.body.supportedGames, ServerTypeInfo.supportedGames);
t.strictSame(res.body.body.supportedGames, TachiConfig.GAMES);
t.strictSame(res.body.body.configs.iidx, GetGameConfig("iidx"));
t.equal(Object.keys(res.body.body.configs).length, ServerTypeInfo.supportedGames.length);
t.equal(Object.keys(res.body.body.configs).length, TachiConfig.GAMES.length);
t.end();
});
@@ -1,5 +1,5 @@
import { Router } from "express";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import { GetGameConfig } from "tachi-common";
import gameRouter from "./_game/router";
@@ -13,15 +13,13 @@ const router: Router = Router({ mergeParams: true });
*/
router.get("/", (req, res) => {
// this line is a bit too 'smart' for its own good, but whatever.
const configs = Object.fromEntries(
ServerTypeInfo.supportedGames.map((e) => [e, GetGameConfig(e)])
);
const configs = Object.fromEntries(TachiConfig.GAMES.map((e) => [e, GetGameConfig(e)]));
return res.status(200).json({
success: true,
description: `Returned support information for ${ServerTypeInfo.supportedGames.length} game(s).`,
description: `Returned support information for ${TachiConfig.GAMES.length} game(s).`,
body: {
supportedGames: ServerTypeInfo.supportedGames,
supportedGames: TachiConfig.GAMES,
configs,
},
});
@@ -14,7 +14,7 @@ import ParseBatchManual from "lib/score-import/import-types/file/batch-manual/pa
import { ParseSolidStateXML } from "lib/score-import/import-types/file/solid-state-squad/parser";
import { ParseMerIIDX } from "lib/score-import/import-types/file/mer-iidx/parser";
import ParsePLIIIDXCSV from "lib/score-import/import-types/file/pli-iidx-csv/parser";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import { RequirePermissions } from "server/middleware/auth";
import { ParseEagIIDX } from "lib/score-import/import-types/api/eag-iidx/parser";
import { ParseEagSDVX } from "lib/score-import/import-types/api/eag-sdvx/parser";
@@ -34,7 +34,7 @@ const ParseMultipartScoredata = CreateMulterSingleUploadMiddleware(
logger
);
const fileImportTypes = ServerTypeInfo.supportedImportTypes.filter((e) => e.startsWith("file/"));
const fileImportTypes = TachiConfig.IMPORT_TYPES.filter((e) => e.startsWith("file/"));
/**
* Import scores from a file. Expects the post request to be multipart, and to provide a scoreData file.
@@ -83,7 +83,7 @@ router.post(
}
);
const apiImportTypes = ServerTypeInfo.supportedImportTypes.filter((e) => e.startsWith("api/"));
const apiImportTypes = TachiConfig.IMPORT_TYPES.filter((e) => e.startsWith("api/"));
/**
* Import scores from another API. This typically will perform a full sync.
@@ -1,20 +1,19 @@
import t from "tap";
import { CreateFakeAuthCookie } from "test-utils/fake-auth";
import { CreateFakeAuthCookie, InsertFakeTokenWithAllPerms } from "test-utils/fake-auth";
import ResetDBState from "test-utils/resets";
import mockApi from "test-utils/mock-api";
import { TestingBarbatosScore } from "test-utils/test-data";
import db from "external/mongo/db";
t.test("POST /ir/barbatos/score/submit", async (t) => {
const cookie = await CreateFakeAuthCookie(mockApi);
t.test("POST /ir/barbatos/score/submit", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(InsertFakeTokenWithAllPerms("mock_token"));
t.test("Should import a valid score", async (t) => {
const res = await mockApi
.post("/ir/barbatos/score/submit")
.set("Cookie", cookie)
.set("Authorization", "Bearer mock_token")
.send(TestingBarbatosScore);
t.equal(res.body.success, true, "Should be successful");
@@ -31,7 +30,10 @@ t.test("POST /ir/barbatos/score/submit", async (t) => {
});
t.test("Should reject an invalid body", async (t) => {
const res = await mockApi.post("/ir/barbatos/score/submit").set("Cookie", cookie).send({});
const res = await mockApi
.post("/ir/barbatos/score/submit")
.set("Authorization", "Bearer mock_token")
.send({});
t.equal(res.body.success, false, "Should not be successful.");
t.equal(res.status, 400, "Should return 400.");
@@ -39,5 +41,24 @@ t.test("POST /ir/barbatos/score/submit", async (t) => {
t.end();
});
t.test("Should require authorisation.", async (t) => {
const res = await mockApi.post("/ir/barbatos/score/submit").send(TestingBarbatosScore);
t.equal(res.statusCode, 401, "Should return 401 for no authorization header.");
t.end();
});
t.test("Should require valid authorisation.", async (t) => {
const res = await mockApi
.post("/ir/barbatos/score/submit")
.set("Authorization", "Bearer invalid_token")
.send(TestingBarbatosScore);
t.equal(res.statusCode, 401, "Should return 401 for invalid authorization header.");
t.end();
});
t.end();
});
@@ -230,6 +230,30 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
t.end();
});
t.test("Should require authentication.", async (t) => {
const res = await mockApi
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "2.0.0")
.send(scoreReq);
t.equal(res.status, 401);
t.end();
});
t.test("Should reject non-corresponding tokens.", async (t) => {
const res = await mockApi
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "2.0.0")
.set("Authorization", "Bearer invalid_token")
.send(scoreReq);
t.equal(res.status, 401);
t.end();
});
t.end();
});
@@ -5,6 +5,7 @@ import CreateLogCtx, { KtLogger } from "lib/logger/logger";
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
import { ParseBeatorajaSingle } from "lib/score-import/import-types/ir/beatoraja/parser";
import { ServerConfig } from "lib/setup/config";
import { RequireNotGuest } from "server/middleware/auth";
import { UpdateClassIfGreater } from "utils/class";
import { GetUserWithIDGuaranteed } from "utils/user";
import { ValidateIRClientVersion } from "./auth";
@@ -21,7 +22,7 @@ router.use(ValidateIRClientVersion);
*
* @name POST /ir/beatoraja/submit-score
*/
router.post("/submit-score", async (req, res) => {
router.post("/submit-score", RequireNotGuest, async (req, res) => {
const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIAuth]!.userID!);
const ParserFunction = (logger: KtLogger) => ParseBeatorajaSingle(req.body, userDoc.id, logger);
@@ -91,7 +92,7 @@ router.post("/submit-score", async (req, res) => {
*
* @name POST /ir/beatoraja/submit-course
*/
router.post("/submit-course", async (req, res) => {
router.post("/submit-course", RequireNotGuest, async (req, res) => {
const charts = req.body.course?.charts;
if (
@@ -63,5 +63,24 @@ t.test("POST /ir/chunitachi/import", (t) => {
t.end();
});
t.test("Should require authentication.", async (t) => {
const res = await mockApi.post("/ir/chunitachi/import").send(chunitachiBody);
t.equal(res.statusCode, 401);
t.end();
});
t.test("Should require a valid auth token.", async (t) => {
const res = await mockApi
.post("/ir/chunitachi/import")
.set("Authorization", "Bearer invalid_token")
.send(chunitachiBody);
t.equal(res.statusCode, 401);
t.end();
});
t.end();
});
@@ -57,5 +57,26 @@ t.test("POST /ir/direct-manual/import", async (t) => {
t.end();
});
t.test("Should require authentication.", async (t) => {
const res = await mockApi
.post("/ir/direct-manual/import")
.send(GetKTDataJSON("./batch-manual/small-file.json"));
t.equal(res.statusCode, 401);
t.end();
});
t.test("Should require a valid auth token.", async (t) => {
const res = await mockApi
.post("/ir/direct-manual/import")
.set("Authorization", "Bearer invalid_token")
.send(GetKTDataJSON("./batch-manual/small-file.json"));
t.equal(res.statusCode, 401);
t.end();
});
t.end();
});
@@ -132,6 +132,29 @@ function TestHeaders(url: string, data: any) {
t.end();
});
t.test("Should require authorization.", async (t) => {
const res = await mockApi
.post(url)
.set("X-Software-Model", "LDJ:J:B:A:2020092900")
.set("User-Agent", "fervidex/1.3.0")
.send(data);
t.equal(res.status, 401, "Should return 401.");
t.type(res.body.error, "string", "Should have an error message.");
const res2 = await mockApi
.post(url)
.set("Authorization", "Bearer invalid_token")
.set("X-Software-Model", "LDJ:J:B:A:2020092900")
.set("User-Agent", "fervidex/1.3.0")
.send(data);
t.equal(res2.status, 401, "Should return 401.");
t.type(res2.body.error, "string", "Should have an error message.");
t.end();
});
}
t.test("POST /ir/fervidex/class/submit", (t) => {
@@ -308,6 +331,8 @@ t.test("POST /ir/fervidex/score/submit", (t) => {
t.equal(res.body.success, false, "Should not be successful");
t.type(res.body.error, "string", "Should have an error prop that is a string.");
t.end();
});
+21 -5
View File
@@ -100,7 +100,7 @@ const RequireInf2ModelHeaderOrForceStatic: RequestHandler = async (req, res, nex
});
}
} catch (err) {
logger.debug(err);
logger.info(`Invalid softID from ${req[SYMBOL_TachiAPIAuth].userID!}.`, { err });
return res.status(400).json({
success: false,
error: `Invalid X-Software-Model.`,
@@ -193,7 +193,7 @@ router.use(
);
/**
* Submits all of a users data to Kamaitachi. This data is extremely minimal,
* Submits all of a users data to Tachi. This data is extremely minimal,
* as only a users Lamp and Score are sent. As such, this is not the prefered
* way of syncing scores outside of INF2, where there is no other way to
* retrieve scores.
@@ -215,13 +215,21 @@ router.post("/profile/submit", RequireInf2ModelHeaderOrForceStatic, async (req,
(logger) => ParseFervidexStatic(req.body, headers, logger)
);
if (!responseData.body.success) {
// in-air rewrite description to error.
// @ts-expect-error Hack!
responseData.body.error = responseData.body.description;
// @ts-expect-error Hack!
delete responseData.body.description;
}
return res.status(responseData.statusCode).json(responseData.body);
});
/**
* Submits a single score to Kamaitachi. In contrast to profile/submit, this
* Submits a single score to Tachi. In contrast to profile/submit, this
* sends the most data (and most accurate data) of any score hook.
* As such, this is the preferred way of submitting IIDX scores to Kamaitachi.
* As such, this is the preferred way of submitting IIDX scores to Tachi.
*
* @name POST /ir/fervidex/score/submit
*/
@@ -248,11 +256,19 @@ router.post("/score/submit", ValidateModelHeader, async (req, res) => {
(logger) => ParseFervidexSingle(req.body, headers, logger)
);
if (!responseData.body.success) {
// in-air rewrite description to error.
// @ts-expect-error Hack!
responseData.body.error = responseData.body.description;
// @ts-expect-error Hack!
delete responseData.body.description;
}
return res.status(responseData.statusCode).json(responseData.body);
});
/**
* Submits the result of a class to Kamaitachi. This contains the dan played
* Submits the result of a class to Tachi. This contains the dan played
* and whether it was achieved.
*
* @name POST /ir/fervidex/class/submit
@@ -0,0 +1,8 @@
import { Router } from "express";
import sv3cRouter from "./sv3c/router";
const router: Router = Router({ mergeParams: true });
router.use("/sv3c", sv3cRouter);
export default router;
@@ -0,0 +1,97 @@
import t from "tap";
import { InsertFakeTokenWithAllPerms } from "test-utils/fake-auth";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
import deepmerge from "deepmerge";
import { TestingKsHookSV3CScore } from "test-utils/test-data";
t.test("POST /ir/kshook/sv3c/score/save", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(InsertFakeTokenWithAllPerms("mock_token"));
// eslint-disable-next-line @typescript-eslint/ban-types
const validSubmit = (data: object) =>
mockApi
.post("/ir/kshook/sv3c/score/save")
.set("Authorization", "Bearer mock_token")
.set("User-Agent", "kshook/0.1.0")
.set("X-Software-Model", "QCV:J:C:A:2021100600")
.send(data);
t.test("Should import a valid score to the database.", async (t) => {
const res = await validSubmit(TestingKsHookSV3CScore);
t.equal(res.status, 200);
t.equal(res.body.success, true);
t.equal(res.body.body.scoreIDs.length, 1, "Should import one score.");
t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores.");
t.end();
});
t.test("Should reject invalid scores to the database.", async (t) => {
const res = await validSubmit({});
t.equal(res.status, 400, "Should return 400 for an empty object.");
t.type(res.body.error, "string", "Should attach an error message.");
const res2 = await validSubmit(
deepmerge(TestingKsHookSV3CScore, {
clear: "INVALID_CLEAR_TYPE",
})
);
t.equal(res2.status, 400, "Should return 400 for an invalid clear type.");
t.type(res2.body.error, "string", "Should attach an error message.");
t.end();
});
t.test("Should reject scores with invalid software models.", async (t) => {
const res = await mockApi
.post("/ir/kshook/sv3c/score/save")
.set("Authorization", "Bearer mock_token")
.set("User-Agent", "kshook/0.1.0")
.set("X-Software-Model", "LDJ:J:C:A:2021100600")
.send(TestingKsHookSV3CScore);
t.equal(res.status, 400, "Should reject an import with invalid software model.");
t.type(res.body.error, "string", "Should have an error message.");
const res2 = await mockApi
.post("/ir/kshook/sv3c/score/save")
.set("Authorization", "Bearer mock_token")
.set("User-Agent", "kshook/0.1.0")
.send(TestingKsHookSV3CScore);
t.equal(res2.status, 400, "Should reject an import with no software model.");
t.type(res2.body.error, "string", "Should have an error message.");
t.end();
});
t.test("Should reject scores with invalid auth.", async (t) => {
const res = await mockApi
.post("/ir/kshook/sv3c/score/save")
.set("Authorization", "Bearer foo")
.set("User-Agent", "kshook/0.1.0")
.set("X-Software-Model", "QCV:J:C:A:2021100600")
.send(TestingKsHookSV3CScore);
t.equal(res.status, 401, "Should reject an import with invalid authentication.");
t.type(res.body.error, "string", "Should have an error message.");
const res2 = await mockApi
.post("/ir/kshook/sv3c/score/save")
.set("User-Agent", "kshook/0.1.0")
.set("X-Software-Model", "QCV:J:C:A:2021100600")
.send(TestingKsHookSV3CScore);
t.equal(res2.status, 401, "Should reject an import with no authentication.");
t.type(res2.body.error, "string", "Should have an error message.");
t.end();
});
t.end();
});
@@ -0,0 +1,104 @@
import { Router, RequestHandler } from "express";
import { MODEL_SDVX3_KONASTE } from "lib/constants/ea3id";
import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
import { ParseKsHookSV3C } from "lib/score-import/import-types/ir/kshook-sv3c/parser";
import { ParseEA3SoftID } from "utils/ea3id";
import { GetUserWithIDGuaranteed } from "utils/user";
const router: Router = Router({ mergeParams: true });
const logger = CreateLogCtx(__filename);
const ValidateHeaders: RequestHandler = (req, res, next) => {
const agent = req.header("User-Agent");
if (!agent) {
logger.debug(
`Rejected KsHook client with no agent from user ${req[SYMBOL_TachiAPIAuth].userID!}.`
);
return res.status(400).json({
success: false,
error: `Invalid User-Agent.`,
});
}
if (!agent.startsWith("kshook/")) {
logger.info(
`Rejected KsHook client with invalid agent ${agent} from user ${req[SYMBOL_TachiAPIAuth]
.userID!}.`
);
return res.status(400).json({
success: false,
error: `Invalid User-Agent ${agent} - expected KsHook client.`,
});
}
// We don't currently need to check the version or anything i don't think.
// We should be good.
const softID = req.header("X-Software-Model");
if (!softID) {
logger.debug(
`Recieved request without X-Software-Model from ${req[SYMBOL_TachiAPIAuth].userID!}.`
);
return res.status(400).json({
success: false,
error: `Invalid X-Software-Model.`,
});
}
try {
const modelInfo = ParseEA3SoftID(softID);
if (modelInfo.model !== MODEL_SDVX3_KONASTE) {
logger.info(
`Recieved unexpected softID ${softID}. Expected ${MODEL_SDVX3_KONASTE} as model.`
);
return res.status(400).json({
success: false,
error: `Invalid softID ${softID}.`,
});
}
} catch (err) {
logger.info(`Invalid softID from ${req[SYMBOL_TachiAPIAuth].userID!}.`, { err });
return res.status(400).json({
success: false,
error: `Invalid X-Software-Model.`,
});
}
return next();
};
router.use(ValidateHeaders);
/**
* Saves a SDVX Konaste score.
*
* @name POST /ir/kshook/sv3c/score/save
*/
router.post("/score/save", async (req, res) => {
const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIAuth].userID!);
const responseData = await ExpressWrappedScoreImportMain(
userDoc,
true,
"ir/kshook-sv3c",
(logger) => ParseKsHookSV3C(req.body, logger)
);
if (!responseData.body.success) {
// in-air rewrite description to error.
// @ts-expect-error Hack!
responseData.body.error = responseData.body.description;
// @ts-expect-error Hack!
delete responseData.body.description;
}
return res.status(responseData.statusCode).json(responseData.body);
});
export default router;
+18 -6
View File
@@ -5,14 +5,21 @@ import directManualIR from "./direct-manual/router";
import fervidexIR from "./fervidex/router";
import uscIR from "./usc/router";
import beatorajaIR from "./beatoraja/router";
import ksHookIR from "./kshook/router";
import { RequireBokutachi, RequireKamaitachi } from "../../middleware/type-require";
import { SetRequestPermissions } from "../../middleware/auth";
import {
FervidexStyleRequireNotGuest,
RequireNotGuest,
SetFervidexStyleRequestPermissions,
SetRequestPermissions,
} from "../../middleware/auth";
const router: Router = Router({ mergeParams: true });
// Common IRs
router.use("/direct-manual", SetRequestPermissions, directManualIR);
router.use("/direct-manual", SetRequestPermissions, RequireNotGuest, directManualIR);
router.use("/kshook", SetFervidexStyleRequestPermissions, FervidexStyleRequireNotGuest, ksHookIR);
// Bokutachi IRs
@@ -24,9 +31,14 @@ router.use("/beatoraja", SetRequestPermissions, RequireBokutachi, beatorajaIR);
// Kamaitachi IRs
router.use(SetRequestPermissions);
router.use("/barbatos", RequireKamaitachi, barbatosIR);
router.use("/chunitachi", RequireKamaitachi, chunitachiIR);
router.use("/fervidex", RequireKamaitachi, fervidexIR);
router.use("/barbatos", SetRequestPermissions, RequireNotGuest, RequireKamaitachi, barbatosIR);
router.use("/chunitachi", SetRequestPermissions, RequireNotGuest, RequireKamaitachi, chunitachiIR);
router.use(
"/fervidex",
SetFervidexStyleRequestPermissions,
FervidexStyleRequireNotGuest,
RequireKamaitachi,
fervidexIR
);
export default router;
+2 -2
View File
@@ -29,7 +29,7 @@ import { GetUSCIRReplayURL } from "lib/cdn/url-format";
import { FormatPrError } from "utils/prudence";
import { USCClientChart } from "./types";
import { HandleOrphanQueue } from "lib/orphan-queue/orphan-queue";
import { ServerConfig } from "lib/setup/config";
import { ServerConfig, TachiConfig } from "lib/setup/config";
const logger = CreateLogCtx(__filename);
@@ -98,7 +98,7 @@ router.get("/", (req, res) =>
description: "IR Request Successful.",
body: {
serverTime: Math.floor(Date.now() / 1000),
serverName: "Bokutachi",
serverName: TachiConfig.NAME,
irVersion: "0.3.1-a",
},
})
+1 -1
View File
@@ -21,7 +21,7 @@ export const TACHI_LAMP_TO_USC: Record<
};
/**
* Converts a Kamaitachi Score to the ServerScoreDocument
* Converts a Tachi Score to the ServerScoreDocument
* as specified in the USCIR spec. This function silently
* returns sentinel values in the case that certain
* fields are null.
+3 -3
View File
@@ -3,7 +3,7 @@ import "express-async-errors";
import expressSession from "express-session";
import { integer } from "tachi-common";
import { RedisClient } from "external/redis/redis";
import { Environment, ServerConfig } from "lib/setup/config";
import { Environment, ServerConfig, TachiConfig } from "lib/setup/config";
import connectRedis from "connect-redis";
import helmet from "helmet";
import CreateLogCtx from "lib/logger/logger";
@@ -19,7 +19,7 @@ if (Environment.nodeEnv !== "test") {
host: "localhost",
port: 6379,
client: RedisClient,
prefix: ServerConfig.TYPE,
prefix: TachiConfig.NAME,
});
}
@@ -27,7 +27,7 @@ const userSessionMiddleware = expressSession({
// append node_env onto the end of the session name
// so we can separate tokens under the same URL.
// say for staging.kamaitachi.xyz
name: `${ServerConfig.TYPE}_${Environment.nodeEnv}_session`,
name: `${TachiConfig.NAME} (${Environment.nodeEnv}) SESSION`,
secret: ServerConfig.SESSION_SECRET,
store,
resave: true,
+22 -27
View File
@@ -1,29 +1,24 @@
[
{
"rgcID": null,
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
"difficulty": "ADV",
"songID": 1,
"playtype": "Single",
"levelNum": 10,
"level": "10",
"flags": {
"IN BASE GAME": true,
"OMNIMIX": false,
"N-1": false
},
"data": {
"inGameID": 1,
"uscEquiv": null,
"arcChartID": "EOZ7FixCDpv"
},
"isPrimary": true,
"versions": [
"booth",
"inf",
"gw",
"heaven",
"vivid"
]
}
{
"rgcID": null,
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
"difficulty": "ADV",
"songID": 1,
"playtype": "Single",
"levelNum": 10,
"level": "10",
"data": {
"inGameID": 1,
"arcChartID": "EOZ7FixCDpv"
},
"isPrimary": true,
"versions": [
"booth",
"inf",
"gw",
"heaven",
"vivid",
"konaste"
]
}
]
+3
View File
@@ -21,6 +21,7 @@ import {
import { DryScore } from "lib/score-import/framework/common/types";
import { BarbatosScore } from "lib/score-import/import-types/ir/barbatos/types";
import { USCClientScore } from "../server/router/ir/usc/types";
import { KsHookSV3CScore } from "lib/score-import/import-types/ir/kshook-sv3c/types";
const file = (name: string) => path.join(__dirname, "/test-data", name);
@@ -38,6 +39,8 @@ export const TestingIIDXSPScore = GetKTDataJSON(
"./tachi/iidx-score.json"
) as ScoreDocument<"iidx:SP">;
export const TestingKsHookSV3CScore = GetKTDataJSON("./kshook-sv3c/base.json") as KsHookSV3CScore;
export const Testing511SPA = GetKTDataJSON("./tachi/iidx-511spa.json") as ChartDocument<"iidx:SP">;
export const Testing511Song = GetKTDataJSON("./tachi/iidx-511-song.json") as SongDocument<"iidx">;
@@ -0,0 +1,24 @@
{
"appeal_id": 2001,
"btn_rate": 182,
"clear": "CLEAR_EXCESSIVE",
"critical": 1184,
"difficulty": "DIFFICULTY_ADVANCED",
"early": 36,
"error": 30,
"gauge": 71,
"grade": "GRADE_AA_PLUS",
"late": 10,
"long_rate": 195,
"max_chain": 158,
"music_id": 1,
"near": 46,
"player_name": "EE",
"rate": "RATE_EXCESSIVE",
"score": 9579365,
"skill_frame": "SKILL_FRAME_NONE",
"skill_level": "SKILL_LEVEL_NONE",
"skill_name": 5,
"track_no": 0,
"vol_rate": 193
}
@@ -1,27 +1,22 @@
{
"rgcID": null,
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
"difficulty": "ADV",
"songID": 1,
"playtype": "Single",
"levelNum": 10,
"level": "10",
"flags": {
"IN BASE GAME": true,
"OMNIMIX": false,
"N-1": false
},
"data": {
"inGameID": 1,
"uscEquiv": null,
"arcChartID": "EOZ7FixCDpv"
},
"isPrimary": true,
"versions": [
"booth",
"inf",
"gw",
"heaven",
"vivid"
]
"rgcID": null,
"chartID": "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
"difficulty": "ADV",
"songID": 1,
"playtype": "Single",
"levelNum": 10,
"level": "10",
"data": {
"inGameID": 1,
"arcChartID": "EOZ7FixCDpv"
},
"isPrimary": true,
"versions": [
"booth",
"inf",
"gw",
"heaven",
"vivid",
"konaste"
]
}
+2 -2
View File
@@ -1,5 +1,5 @@
import crypto from "crypto";
import { ServerTypeInfo } from "lib/setup/config";
import { TachiConfig } from "lib/setup/config";
import { Game, Playtypes, GetGameConfig, GamePTConfig } from "tachi-common";
// https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
@@ -62,7 +62,7 @@ export function HasOwnProperty<T>(obj: T, key: string | number | symbol): key is
}
export function IsValidGame(str: string): str is Game {
return !!ServerTypeInfo.supportedGames.includes(str as Game);
return !!TachiConfig.GAMES.includes(str as Game);
}
export function IsValidPlaytype(game: Game, str: string): str is Playtypes[Game] {