diff --git a/server/.github/test.conf.json5 b/server/.github/test.conf.json5 index 9fa254e07..c49393dd4 100644 --- a/server/.github/test.conf.json5 +++ b/server/.github/test.conf.json5 @@ -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", + ], + }, } diff --git a/server/package.json b/server/package.json index dfea4ad47..9fb46e008 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index e50df4826..203a78259 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -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: diff --git a/server/scripts/__KT_DATABASE_MIGRATION/old-db.ts b/server/scripts/__KT_DATABASE_MIGRATION/old-db.ts index 3ed4ec313..e1335772f 100644 --- a/server/scripts/__KT_DATABASE_MIGRATION/old-db.ts +++ b/server/scripts/__KT_DATABASE_MIGRATION/old-db.ts @@ -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(() => { diff --git a/server/scripts/__KT_DATABASE_MIGRATION/user-goals.ts b/server/scripts/__KT_DATABASE_MIGRATION/user-goals.ts index 2a901252c..a1013a916 100644 --- a/server/scripts/__KT_DATABASE_MIGRATION/user-goals.ts +++ b/server/scripts/__KT_DATABASE_MIGRATION/user-goals.ts @@ -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); diff --git a/server/scripts/single-use/add-tierlist-info.ts b/server/scripts/single-use/add-tierlist-info.ts index 25dd2b1d2..2c512a0fc 100644 --- a/server/scripts/single-use/add-tierlist-info.ts +++ b/server/scripts/single-use/add-tierlist-info.ts @@ -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 } }, diff --git a/server/scripts/sync-database/sync-database.ts b/server/scripts/sync-database/sync-database.ts index c83e46d68..4e621dc13 100644 --- a/server/scripts/sync-database/sync-database.ts +++ b/server/scripts/sync-database/sync-database.ts @@ -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[] = []; 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[] = []; 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; } diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index 3e8c47ca9..32d0e7a25 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -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> = { const indexes: Partial> = 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), diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts index 564c873bf..305b0ccec 100644 --- a/server/src/external/mongo/schemas.ts +++ b/server/src/external/mongo/schemas.ts @@ -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) => { if (!parent.game || typeof parent.game !== "string" || !IsValidGame(parent.game)) { throw new Error(`Invalid Schema, need game to base IsValidPlaytype off of.`); diff --git a/server/src/external/redis/redis-IPC.ts b/server/src/external/redis/redis-IPC.ts index d50d5eaf4..d74cbeb46 100644 --- a/server/src/external/redis/redis-IPC.ts +++ b/server/src/external/redis/redis-IPC.ts @@ -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 = {}; const SubClient = redis.createClient(); const PubClient = redis.createClient(); -const PREFIX = ServerConfig.TYPE.toUpperCase(); // KTCHI or BTCHI +const PREFIX = TachiConfig.NAME.toUpperCase(); export function RedisPub(channel: T, data: RedisIPCData[T]) { PubClient.publish(`${PREFIX}-${channel}`, JSON.stringify(data)); diff --git a/server/src/lib/constants/ea3id.ts b/server/src/lib/constants/ea3id.ts index d07e48a95..1977ec30f 100644 --- a/server/src/lib/constants/ea3id.ts +++ b/server/src/lib/constants/ea3id.ts @@ -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"; diff --git a/server/src/lib/email/formats.ts b/server/src/lib/email/formats.ts index 968ed657f..6847a7d80 100644 --- a/server/src/lib/email/formats.ts +++ b/server/src/lib/email/formats.ts @@ -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) { - ${ServerTypeInfo.name} + ${TachiConfig.NAME} ${innerHTML} diff --git a/server/src/lib/score-import/framework/score-importing/score-importing.ts b/server/src/lib/score-import/framework/score-importing/score-importing.ts index 88a957f09..bbeb670c3 100644 --- a/server/src/lib/score-import/framework/score-importing/score-importing.ts +++ b/server/src/lib/score-import/framework/score-importing/score-importing.ts @@ -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. diff --git a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts index 337e4891f..6e59616fd 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts @@ -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}.` ); } diff --git a/server/src/lib/score-import/import-types/common/batch-manual/parser.ts b/server/src/lib/score-import/import-types/common/batch-manual/parser.ts index c26631905..1d4c7906d 100644 --- a/server/src/lib/score-import/import-types/common/batch-manual/parser.ts +++ b/server/src/lib/score-import/import-types/common/batch-manual/parser.ts @@ -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(", ")}.` ); } diff --git a/server/src/lib/score-import/import-types/common/types.ts b/server/src/lib/score-import/import-types/common/types.ts index 219221f72..32aed2650 100644 --- a/server/src/lib/score-import/import-types/common/types.ts +++ b/server/src/lib/score-import/import-types/common/types.ts @@ -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; diff --git a/server/src/lib/score-import/import-types/converters.ts b/server/src/lib/score-import/import-types/converters.ts index 097857dd5..fc3b375c3 100644 --- a/server/src/lib/score-import/import-types/converters.ts +++ b/server/src/lib/score-import/import-types/converters.ts @@ -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, }; diff --git a/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts b/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts index dfc40aa4a..2c9dce86e 100644 --- a/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts +++ b/server/src/lib/score-import/import-types/ir/barbatos/converter.test.ts @@ -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, }, diff --git a/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts b/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts index 36acf1dad..062897cb7 100644 --- a/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts +++ b/server/src/lib/score-import/import-types/ir/beatoraja/converter.ts @@ -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 { + 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(); +}); diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv3c/converter.ts b/server/src/lib/score-import/import-types/ir/kshook-sv3c/converter.ts new file mode 100644 index 000000000..c0df525cb --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv3c/converter.ts @@ -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 = 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"; +} diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv3c/parser.test.ts b/server/src/lib/score-import/import-types/ir/kshook-sv3c/parser.test.ts new file mode 100644 index 000000000..c29833b71 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv3c/parser.test.ts @@ -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(); +}); diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv3c/parser.ts b/server/src/lib/score-import/import-types/ir/kshook-sv3c/parser.ts new file mode 100644 index 000000000..6ce5ceaa1 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv3c/parser.ts @@ -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, + logger: KtLogger +): ParserFunctionReturns { + // 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; + } +} diff --git a/server/src/lib/score-import/import-types/ir/kshook-sv3c/types.ts b/server/src/lib/score-import/import-types/ir/kshook-sv3c/types.ts new file mode 100644 index 000000000..0b7f4e331 --- /dev/null +++ b/server/src/lib/score-import/import-types/ir/kshook-sv3c/types.ts @@ -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; +} diff --git a/server/src/lib/search/search.ts b/server/src/lib/search/search.ts index 54aa7b7b7..4d4dc0e2a 100644 --- a/server/src/lib/search/search.ts +++ b/server/src/lib/search/search.ts @@ -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)); } diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts index 667bddddf..0324c21f8 100644 --- a/server/src/lib/setup/config.ts +++ b/server/src/lib/setup/config.ts @@ -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 diff --git a/server/src/main.ts b/server/src/main.ts index 8ce69c246..b356dcfab 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -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...`); diff --git a/server/src/server/middleware/auth.ts b/server/src/server/middleware/auth.ts index 7a1fa8f83..a21bc2e11 100644 --- a/server/src/server/middleware/auth.ts +++ b/server/src/server/middleware/auth.ts @@ -22,6 +22,8 @@ export const AllPermissions: Record = { 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 = { * * 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"); diff --git a/server/src/server/middleware/rate-limiter.ts b/server/src/server/middleware/rate-limiter.ts index e3606ecc5..b5c9c683c 100644 --- a/server/src/server/middleware/rate-limiter.ts +++ b/server/src/server/middleware/rate-limiter.ts @@ -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() { diff --git a/server/src/server/middleware/type-require.ts b/server/src/server/middleware/type-require.ts index 3b2552b76..9fba564eb 100644 --- a/server/src/server/middleware/type-require.ts +++ b/server/src/server/middleware/type-require.ts @@ -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(); } diff --git a/server/src/server/router/api/v1/admin/router.ts b/server/src/server/router/api/v1/admin/router.ts index 425bf1927..a32d3f140 100644 --- a/server/src/server/router/api/v1/admin/router.ts +++ b/server/src/server/router/api/v1/admin/router.ts @@ -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) => { diff --git a/server/src/server/router/api/v1/auth/router.ts b/server/src/server/router/api/v1/auth/router.ts index dccc3493b..0682c5b3b 100644 --- a/server/src/server/router/api/v1/auth/router.ts +++ b/server/src/server/router/api/v1/auth/router.ts @@ -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); diff --git a/server/src/server/router/api/v1/games/_game/middleware.ts b/server/src/server/router/api/v1/games/_game/middleware.ts index eaa50a1ce..bc1f33244 100644 --- a/server/src/server/router/api/v1/games/_game/middleware.ts +++ b/server/src/server/router/api/v1/games/_game/middleware.ts @@ -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(", ")}`, }); } diff --git a/server/src/server/router/api/v1/games/router.test.ts b/server/src/server/router/api/v1/games/router.test.ts index 37290568f..5fad61e8b 100644 --- a/server/src/server/router/api/v1/games/router.test.ts +++ b/server/src/server/router/api/v1/games/router.test.ts @@ -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(); }); diff --git a/server/src/server/router/api/v1/games/router.ts b/server/src/server/router/api/v1/games/router.ts index b85e13205..b74f08aad 100644 --- a/server/src/server/router/api/v1/games/router.ts +++ b/server/src/server/router/api/v1/games/router.ts @@ -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, }, }); diff --git a/server/src/server/router/api/v1/import/router.ts b/server/src/server/router/api/v1/import/router.ts index 92f334345..75228efc6 100644 --- a/server/src/server/router/api/v1/import/router.ts +++ b/server/src/server/router/api/v1/import/router.ts @@ -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. diff --git a/server/src/server/router/ir/barbatos/router.test.ts b/server/src/server/router/ir/barbatos/router.test.ts index bb2864550..5cd368639 100644 --- a/server/src/server/router/ir/barbatos/router.test.ts +++ b/server/src/server/router/ir/barbatos/router.test.ts @@ -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(); }); diff --git a/server/src/server/router/ir/beatoraja/router.test.ts b/server/src/server/router/ir/beatoraja/router.test.ts index 5edc86e5e..dfc5cf75a 100644 --- a/server/src/server/router/ir/beatoraja/router.test.ts +++ b/server/src/server/router/ir/beatoraja/router.test.ts @@ -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(); }); diff --git a/server/src/server/router/ir/beatoraja/router.ts b/server/src/server/router/ir/beatoraja/router.ts index 93b2d83dc..39e2b8626 100644 --- a/server/src/server/router/ir/beatoraja/router.ts +++ b/server/src/server/router/ir/beatoraja/router.ts @@ -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 ( diff --git a/server/src/server/router/ir/chunitachi/router.test.ts b/server/src/server/router/ir/chunitachi/router.test.ts index f0efe881b..8dea213f1 100644 --- a/server/src/server/router/ir/chunitachi/router.test.ts +++ b/server/src/server/router/ir/chunitachi/router.test.ts @@ -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(); }); diff --git a/server/src/server/router/ir/direct-manual/router.test.ts b/server/src/server/router/ir/direct-manual/router.test.ts index de087940e..c65598947 100644 --- a/server/src/server/router/ir/direct-manual/router.test.ts +++ b/server/src/server/router/ir/direct-manual/router.test.ts @@ -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(); }); diff --git a/server/src/server/router/ir/fervidex/router.test.ts b/server/src/server/router/ir/fervidex/router.test.ts index 06d431aca..5ed1c97a3 100644 --- a/server/src/server/router/ir/fervidex/router.test.ts +++ b/server/src/server/router/ir/fervidex/router.test.ts @@ -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(); }); diff --git a/server/src/server/router/ir/fervidex/router.ts b/server/src/server/router/ir/fervidex/router.ts index 51ddb0ddc..a7001b208 100644 --- a/server/src/server/router/ir/fervidex/router.ts +++ b/server/src/server/router/ir/fervidex/router.ts @@ -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 diff --git a/server/src/server/router/ir/kshook/router.ts b/server/src/server/router/ir/kshook/router.ts new file mode 100644 index 000000000..e1b1da1a5 --- /dev/null +++ b/server/src/server/router/ir/kshook/router.ts @@ -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; diff --git a/server/src/server/router/ir/kshook/sv3c/router.test.ts b/server/src/server/router/ir/kshook/sv3c/router.test.ts new file mode 100644 index 000000000..37670af26 --- /dev/null +++ b/server/src/server/router/ir/kshook/sv3c/router.test.ts @@ -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(); +}); diff --git a/server/src/server/router/ir/kshook/sv3c/router.ts b/server/src/server/router/ir/kshook/sv3c/router.ts new file mode 100644 index 000000000..0f2843ac4 --- /dev/null +++ b/server/src/server/router/ir/kshook/sv3c/router.ts @@ -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; diff --git a/server/src/server/router/ir/router.ts b/server/src/server/router/ir/router.ts index a1267669d..a53ac1ab4 100644 --- a/server/src/server/router/ir/router.ts +++ b/server/src/server/router/ir/router.ts @@ -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; diff --git a/server/src/server/router/ir/usc/router.ts b/server/src/server/router/ir/usc/router.ts index 41f4dd3f8..55c09a6a4 100644 --- a/server/src/server/router/ir/usc/router.ts +++ b/server/src/server/router/ir/usc/router.ts @@ -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", }, }) diff --git a/server/src/server/router/ir/usc/usc.ts b/server/src/server/router/ir/usc/usc.ts index 2d0bc8729..38e3deb7c 100644 --- a/server/src/server/router/ir/usc/usc.ts +++ b/server/src/server/router/ir/usc/usc.ts @@ -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. diff --git a/server/src/server/server.ts b/server/src/server/server.ts index 2e3d5ab6a..f17697fe4 100644 --- a/server/src/server/server.ts +++ b/server/src/server/server.ts @@ -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, diff --git a/server/src/test-utils/mock-db/charts-sdvx.json b/server/src/test-utils/mock-db/charts-sdvx.json index 4cbbac926..98e1f850e 100644 --- a/server/src/test-utils/mock-db/charts-sdvx.json +++ b/server/src/test-utils/mock-db/charts-sdvx.json @@ -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" + ] + } ] \ No newline at end of file diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index f7fca6aea..9b94f9d0d 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -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">; diff --git a/server/src/test-utils/test-data/kshook-sv3c/base.json b/server/src/test-utils/test-data/kshook-sv3c/base.json new file mode 100644 index 000000000..6140145c4 --- /dev/null +++ b/server/src/test-utils/test-data/kshook-sv3c/base.json @@ -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 +} \ No newline at end of file diff --git a/server/src/test-utils/test-data/tachi/sdvx-albida-chart.json b/server/src/test-utils/test-data/tachi/sdvx-albida-chart.json index a06cca42f..327cdd34a 100644 --- a/server/src/test-utils/test-data/tachi/sdvx-albida-chart.json +++ b/server/src/test-utils/test-data/tachi/sdvx-albida-chart.json @@ -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" + ] } \ No newline at end of file diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 7190fecae..3fa7385f4 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -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(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] {