diff --git a/server/src/lib/email/client.ts b/server/src/lib/email/client.ts index d8e0c6c44..fbfd4ecba 100644 --- a/server/src/lib/email/client.ts +++ b/server/src/lib/email/client.ts @@ -50,19 +50,28 @@ export function SendEmail( return; } - if (!transporter) { + if (!transporter || !ServerConfig.EMAIL_CONFIG) { logger.debug(`Stubbed out SendEmail as no EMAIL_CONFIG was set.`); return; } logger.verbose(`Sending email to ${to}.`); - return transporter.sendMail({ - from: ServerConfig.EMAIL_CONFIG!.FROM, - to, - subject, - html: htmlContent, - text: textContent, - dkim: ServerConfig.EMAIL_CONFIG?.DKIM, - }); + return transporter + .sendMail({ + from: ServerConfig.EMAIL_CONFIG.FROM, + to, + subject, + html: htmlContent, + text: textContent, + dkim: ServerConfig.EMAIL_CONFIG.DKIM, + }) + .catch((err: unknown) => { + logger.info(`Failed to send email to ${to}.`, { + err, + subject, + textContent, + htmlContent, + }); + }); } diff --git a/server/src/lib/logger/logger.test.ts b/server/src/lib/logger/logger.test.ts index 7c0fc36df..441539009 100644 --- a/server/src/lib/logger/logger.test.ts +++ b/server/src/lib/logger/logger.test.ts @@ -8,6 +8,10 @@ const LOG_LEVEL = ServerConfig.LOGGER_CONFIG.LOG_LEVEL; t.test("Logger Tests", (t) => { const logger = CreateLogCtx(__filename); + if (!Transports[0]) { + throw new Error(`No transports were defined? Can't perform logger tests.`) + } + Transports[0].level = "debug"; // lol logger.debug("Debug Message Test"); diff --git a/server/src/lib/score-import/framework/common/converter-failures.ts b/server/src/lib/score-import/framework/common/converter-failures.ts index 16cec5002..de7f440b2 100644 --- a/server/src/lib/score-import/framework/common/converter-failures.ts +++ b/server/src/lib/score-import/framework/common/converter-failures.ts @@ -3,10 +3,11 @@ import type { ImportTypeContextMap, ImportTypeDataMap } from "../../import-types/common/types"; import type { ImportTypes } from "tachi-common"; -export class ConverterFailure { +export class ConverterFailure extends Error { message: string; constructor(message: string) { + super(); this.message = message; } } diff --git a/server/src/lib/score-import/framework/common/score-utils.ts b/server/src/lib/score-import/framework/common/score-utils.ts index ce4398f22..56a39ac60 100644 --- a/server/src/lib/score-import/framework/common/score-utils.ts +++ b/server/src/lib/score-import/framework/common/score-utils.ts @@ -32,7 +32,7 @@ export function GetGradeFromPercent( } // (hey, this for loop is backwards!) - for (let i = boundaries.length; i >= 0; i--) { + for (let i = boundaries.length - 1; i >= 0; i--) { if (percent + Number.EPSILON >= NotNullish(boundaries[i])) { return grades[i] as Grades[I]; } diff --git a/server/src/lib/score-import/framework/common/string-asserts.test.ts b/server/src/lib/score-import/framework/common/string-asserts.test.ts index 342aad78e..db8e7f782 100644 --- a/server/src/lib/score-import/framework/common/string-asserts.test.ts +++ b/server/src/lib/score-import/framework/common/string-asserts.test.ts @@ -25,66 +25,67 @@ function astrp(v: string) { t.test("#AssertStrAsPositiveInt", (t) => { t.strictSame( astr("---"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- ---.)`), "Should reject non-int input" ); t.strictSame( astr("1.4"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 1.4.)`), "Should reject float input" ); t.strictSame( astr("NaN"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- NaN.)`), "Should reject NaN" ); t.strictSame( astr("-0.3"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- -0.3.)`), "Should reject negative float input" ); t.strictSame( astr("0xFF"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 0xFF.)`), "Should reject hex input" ); t.strictSame( astr("0b11"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 0b11.)`), "Should reject binary input" ); t.strictSame( astr("0o77"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 0o77.)`), "Should reject oct input" ); t.strictSame( astr("--1"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- --1.)`), "Should reject double neg input" ); t.strictSame( astr("12f"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 12f.)`), "Should reject valid parseInt but invalid Number() input" ); t.strictSame( astr(`${Number.MAX_SAFE_INTEGER.toString()}000`), - new InvalidScoreFailure(`err (Not an integer.)`), + // @warn Counterintuitive errmsg here. It is an int! + new InvalidScoreFailure(`err (Not an integer -- ${Number.MAX_SAFE_INTEGER.toString()}000.)`), "Should reject numbers > max_safe_integer." ); t.strictSame( astr("-1"), - new InvalidScoreFailure(`err (Was negative.)`), + new InvalidScoreFailure(`err (Was negative -- -1.)`), "Should reject negative integer input" ); @@ -98,78 +99,79 @@ t.test("#AssertStrAsPositiveInt", (t) => { t.test("#AssertStrAsPositiveNonZeroInt", (t) => { t.strictSame( astrp("---"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- ---.)`), "Should reject non-int input" ); t.strictSame( astrp("1.4"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 1.4.)`), "Should reject float input" ); t.strictSame( astrp("NaN"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- NaN.)`), "Should reject NaN" ); t.strictSame( astrp("-0.3"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- -0.3.)`), "Should reject negative float input" ); t.strictSame( astrp("0xFF"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 0xFF.)`), "Should reject hex input" ); t.strictSame( astrp("0b11"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 0b11.)`), "Should reject binary input" ); t.strictSame( astrp("0o77"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 0o77.)`), "Should reject oct input" ); t.strictSame( astrp("--1"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- --1.)`), "Should reject double neg input" ); t.strictSame( astrp("12f"), - new InvalidScoreFailure(`err (Not an integer.)`), + new InvalidScoreFailure(`err (Not an integer -- 12f.)`), "Should reject valid parseInt but invalid Number() input" ); t.strictSame( astrp(`${Number.MAX_SAFE_INTEGER.toString()}000`), - new InvalidScoreFailure(`err (Not an integer.)`), + // @warn Counterintuitive errmsg here. It is an int! + new InvalidScoreFailure(`err (Not an integer -- ${Number.MAX_SAFE_INTEGER.toString()}000.)`), "Should reject numbers > max_safe_integer." ); t.strictSame( astrp("-1"), - new InvalidScoreFailure(`err (Was negative or zero.)`), + new InvalidScoreFailure(`err (Was negative or zero -- -1.)`), "Should reject negative integer input" ); - +1 t.strictSame( astrp("-0"), - new InvalidScoreFailure(`err (Was negative or zero.)`), + new InvalidScoreFailure(`err (Was negative or zero -- 0.)`), "Should reject negative 0 input" ); t.strictSame( astrp("0"), - new InvalidScoreFailure(`err (Was negative or zero.)`), + new InvalidScoreFailure(`err (Was negative or zero -- 0.)`), "Should reject 0 input" ); diff --git a/server/src/lib/score-import/framework/goals/goals.test.ts b/server/src/lib/score-import/framework/goals/goals.test.ts index 9d37b44a2..c886723c0 100644 --- a/server/src/lib/score-import/framework/goals/goals.test.ts +++ b/server/src/lib/score-import/framework/goals/goals.test.ts @@ -2,7 +2,7 @@ import t from "tap"; import db from "external/mongo/db"; import ResetDBState from "test-utils/resets"; import { GetRelevantFolderGoals, GetRelevantGoals, UpdateGoalsForUser, ProcessGoal } from "./goals"; -import { GoalDocument, GoalSubscriptionDocument } from "tachi-common"; +import { ChartDocument, GoalDocument, GoalSubscriptionDocument, SongDocument } from "tachi-common"; import { CreateFolderChartLookup } from "utils/folder"; import { GetKTDataJSON, @@ -93,8 +93,8 @@ t.test("#GetRelevantGoals", (t) => { // use the real data so we have enough charts loaded for this to test properly. await db.songs.iidx.remove({}); await db.charts.iidx.remove({}); - await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json")); - await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json")); + await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json") as ChartDocument<"iidx:SP"|"iidx:DP">[]); + await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json") as SongDocument<"iidx">[]); const lotsOfCharts = await db.charts.iidx.find({}, { limit: 20 }); const goals: GoalDocument[] = lotsOfCharts.map((e) => ({ diff --git a/server/src/lib/score-import/framework/orphans/orphans.test.ts b/server/src/lib/score-import/framework/orphans/orphans.test.ts index bf81e6d26..9f65fb37d 100644 --- a/server/src/lib/score-import/framework/orphans/orphans.test.ts +++ b/server/src/lib/score-import/framework/orphans/orphans.test.ts @@ -133,7 +133,7 @@ t.test("#ReprocessOrphan", (t) => { { success: true, type: "ScoreImported", - message: null, + message: "Imported score R7a3a2b04bd4882ec06c198d78297fe3d56561502c7b134c067214bbfdf4f1602.", content: { score: { game: "iidx", diff --git a/server/src/lib/score-import/framework/sessions/sessions.test.ts b/server/src/lib/score-import/framework/sessions/sessions.test.ts index 93e625c4a..9f3e04d01 100644 --- a/server/src/lib/score-import/framework/sessions/sessions.test.ts +++ b/server/src/lib/score-import/framework/sessions/sessions.test.ts @@ -45,7 +45,7 @@ t.test("#CreateSessions", (t) => { userID: 1, importType: "ir/direct-manual", // name: "adjective1 adjective2 noun1", - sessionID: res[0].sessionID, + sessionID: res[0]?.sessionID, desc: null, game: "iidx", playtype: "SP", @@ -137,7 +137,7 @@ t.test("#LoadScoresIntoSessions", (t) => { t.equal(sessions.length, 1); - t.strictSame(sessions[0].scoreInfo, [ + t.strictSame(sessions[0]?.scoreInfo, [ { scoreID: "SCORE_ID_1", isNewScore: true, @@ -183,7 +183,7 @@ t.test("#LoadScoresIntoSessions", (t) => { t.equal(sessions.length, 1); - t.strictSame(sessions[0].scoreInfo, [ + t.strictSame(sessions[0]?.scoreInfo, [ { scoreID: "SCORE_ID_2", isNewScore: true, @@ -246,7 +246,7 @@ t.test("#LoadScoresIntoSessions", (t) => { t.equal(sessions.length, 2); - t.strictSame(sessions[0].scoreInfo, [ + t.strictSame(sessions[0]?.scoreInfo, [ { scoreID: "SCORE_ID_1", isNewScore: true, @@ -260,7 +260,7 @@ t.test("#LoadScoresIntoSessions", (t) => { isNewScore: true, }, ]); - t.strictSame(sessions[1].scoreInfo, [ + t.strictSame(sessions[1]?.scoreInfo, [ { scoreID: "SCORE_ID_4", isNewScore: true, @@ -317,7 +317,7 @@ t.test("#LoadScoresIntoSessions", (t) => { t.equal(sessions.length, 1); - t.strictSame(sessions[0].scoreInfo, [ + t.strictSame(sessions[0]?.scoreInfo, [ { scoreID: "EXAMPLE_SCORE_ID", isNewScore: true, @@ -336,7 +336,7 @@ t.test("#LoadScoresIntoSessions", (t) => { }, ]); - t.equal(sessions[0].timeEnded, start + 2000); + t.equal(sessions[0]?.timeEnded, start + 2000); t.end(); }); @@ -380,7 +380,7 @@ t.test("#LoadScoresIntoSessions", (t) => { t.equal(sessions.length, 1); - t.strictSame(sessions[0].scoreInfo, [ + t.strictSame(sessions[0]?.scoreInfo, [ { scoreID: "EXAMPLE_SCORE_ID", isNewScore: true, @@ -399,7 +399,7 @@ t.test("#LoadScoresIntoSessions", (t) => { }, ]); - t.equal(sessions[0].timeStarted, start - 2000); + t.equal(sessions[0]?.timeStarted, start - 2000); t.end(); }); @@ -427,7 +427,7 @@ t.test("#LoadScoresIntoSessions", (t) => { t.equal(sessions.length, 1); - t.strictSame(sessions[0].scoreInfo, [ + t.strictSame(sessions[0]?.scoreInfo, [ { scoreID: "TESTING_SCORE_ID", isNewScore: false, diff --git a/server/src/lib/score-import/framework/user-game-stats/builtin-class-handlers.test.ts b/server/src/lib/score-import/framework/user-game-stats/builtin-class-handlers.test.ts index 3ba966618..72c6f943e 100644 --- a/server/src/lib/score-import/framework/user-game-stats/builtin-class-handlers.test.ts +++ b/server/src/lib/score-import/framework/user-game-stats/builtin-class-handlers.test.ts @@ -12,7 +12,7 @@ const logger = CreateLogCtx(__filename); t.test("#CalculateGitadoraColour", (t) => { t.strictSame( - CalculateGitadoraColour("gitadora", "Gita", 1, { skill: 1500 }, logger), + CalculateGitadoraColour("gitadora", "Gita", 1, { skill: 1500 }), { colour: GitadoraColours.ORANGE_GRADIENT, }, diff --git a/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts b/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts index 5ab5b0d59..eb4485ab1 100644 --- a/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts +++ b/server/src/lib/score-import/import-types/common/batch-manual/converter.test.ts @@ -1,9 +1,9 @@ import deepmerge from "deepmerge"; import CreateLogCtx from "lib/logger/logger"; -import { BatchManualScore, Game } from "tachi-common"; +import { BatchManualScore, ChartDocument, Game, SongDocument } from "tachi-common"; import t from "tap"; import ResetDBState from "test-utils/resets"; -import { GetKTDataJSON, Testing511Song, Testing511SPA } from "test-utils/test-data"; +import { BMSGazerChart, BMSGazerSong, GetKTDataJSON, Testing511Song, Testing511SPA } from "test-utils/test-data"; import { EscapeStringRegexp } from "utils/misc"; import { InvalidScoreFailure } from "../../../framework/common/converter-failures"; import { ConverterBatchManual, ResolveChartFromSong, ResolveMatchTypeToKTData } from "./converter"; @@ -104,9 +104,6 @@ t.test("#ResolveMatchTypeToKTData", (t) => { const GAZER17MD5 = "38616b85332037cc12924f2ae2840262"; const GAZER17SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d"; - const gazerSong = GetKTDataJSON("./tachi/bms-gazer-song.json"); - const gazerChart = GetKTDataJSON("./tachi/bms-gazer-chart.json"); - const bmsContext: BatchManualContext = deepmerge(context, { game: "bms", playtype: "7K" }); const resMD5 = await ResolveMatchTypeToKTData( @@ -121,7 +118,7 @@ t.test("#ResolveMatchTypeToKTData", (t) => { t.hasStrict( resMD5, - { song: gazerSong, chart: gazerChart }, + { song: BMSGazerSong, chart: BMSGazerChart }, "Should return the right song and chart." ); @@ -137,7 +134,7 @@ t.test("#ResolveMatchTypeToKTData", (t) => { t.hasStrict( resSHA256, - { song: gazerSong, chart: gazerChart }, + { song: BMSGazerSong, chart: BMSGazerChart }, "Should return the right song and chart." ); diff --git a/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.test.ts b/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.test.ts index 6a3339f01..94d1027e6 100644 --- a/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.test.ts +++ b/server/src/lib/score-import/import-types/common/eamusement-iidx-csv/parser.test.ts @@ -219,7 +219,7 @@ t.test("#ParseEamusementCSV", (t) => { t.throws( () => GenericParseEamIIDXCSV(validSPFile, {}, "e-amusement", logger), - new ScoreImportFatalError(400, "Invalid playtype of Nothing given.") + new ScoreImportFatalError(400, "Invalid playtype of undefined given.") ); let { context } = GenericParseEamIIDXCSV( diff --git a/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts b/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts index 72aae1db8..177e2d673 100644 --- a/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts +++ b/server/src/lib/score-import/import-types/file/eamusement-sdvx-csv/converter.test.ts @@ -8,7 +8,14 @@ import { SDVXEamusementCSVData } from "./types"; const logger = CreateLogCtx(__filename); -const parsedScore = GetKTDataJSON("./eamusement-sdvx-csv/parsed-data.json"); +const parsedScore = { + "title": "ALBIDA Powerless Mix", + "difficulty": "ADVANCED", + "level": "10", + "lamp": "EXCESSIVE COMPLETE", + "score": "9310699", + "exscore": "0" +}; t.test("#ConvertEamSDVXCSV", (t) => { t.beforeEach(ResetDBState); diff --git a/server/src/lib/score-import/import-types/file/mer-iidx/converter.test.ts b/server/src/lib/score-import/import-types/file/mer-iidx/converter.test.ts index 9d6ba687a..f3bb37b0f 100644 --- a/server/src/lib/score-import/import-types/file/mer-iidx/converter.test.ts +++ b/server/src/lib/score-import/import-types/file/mer-iidx/converter.test.ts @@ -16,12 +16,21 @@ const logger = CreateLogCtx(__filename); t.test("#ConvertFileMerIIDX", (t) => { t.beforeEach(ResetDBState); + + const MerScore = { + "music_id": 1000, + "play_type": "SINGLE", + "diff_type": "ANOTHER", + "score": 1000, + "miss_count": 21, + "clear_type": "CLEAR", + "update_time": "2021-03-24 07:15:22" + } as const; function merc(g: Partial = {}) { return ConvertFileMerIIDX(deepmerge(MerScore, g), {}, "file/mer-iidx", logger); } - const MerScore = GetKTDataJSON("./mer/merscore.json"); t.test("Valid Conversion", async (t) => { const res = await ConvertFileMerIIDX(MerScore, {}, "file/mer-iidx", logger); diff --git a/server/src/lib/score-import/import-types/file/mer-iidx/parser.test.ts b/server/src/lib/score-import/import-types/file/mer-iidx/parser.test.ts index 0df074998..73793a518 100644 --- a/server/src/lib/score-import/import-types/file/mer-iidx/parser.test.ts +++ b/server/src/lib/score-import/import-types/file/mer-iidx/parser.test.ts @@ -17,7 +17,7 @@ t.test("#ParseMerIIDX", (t) => { function mrfj(obj: unknown) { return ParseMerIIDX( - MockMulterFile(Buffer.from(JSON.stringify(obj)), "buffer.json"), + MockMulterFile(Buffer.from(JSON.stringify([obj])), "buffer.json"), {}, logger ); @@ -75,7 +75,26 @@ t.test("#ParseMerIIDX", (t) => { t.end(); }); - const baseScore = GetKTDataJSON("./mer/base.json"); + const baseScore = + { + "music_id": 3007, + "version_id": 3, + "version_name": "3rd style", + "music_name": "Presto", + "play_type": "SINGLE", + "diff_type": "HYPER", + "score": 566, + "score_percent": 56.8273, + "miss_count": 46, + "grade": "B", + "clear_type": "NO PLAY", + "clear_type_id": 0, + "level": 8, + "note": 498, + "play_count": 6, + "update_time": "2019-06-01 19:56:59", + "score_diff_str": "B+12" + }; t.test("Should throw on invalid play_type", (t) => { t.throws(() => mrfj(deepmerge(baseScore, { play_type: "INVALID" }))); diff --git a/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts b/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts index fa86fdef0..dc6052755 100644 --- a/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts +++ b/server/src/lib/score-import/import-types/file/solid-state-squad/converter.test.ts @@ -5,6 +5,7 @@ import ResetDBState from "test-utils/resets"; import { GetKTDataJSON, LoadTachiIIDXData, + MockParsedS3Score, Testing511Song, Testing511SPA, } from "test-utils/test-data"; @@ -20,11 +21,12 @@ function cfile(data: S3Score) { t.test("#ConvertFileS3", (t) => { t.beforeEach(ResetDBState); t.beforeEach(() => { - delete BaseS3Score._id; // just incase + // @ts-expect-error hacky just-incase. + delete MockParsedS3Score._id; }); function mfile(merge: Partial) { - return cfile(deepmerge(BaseS3Score, merge)); + return cfile(deepmerge(MockParsedS3Score, merge)); } const dryScore = { @@ -51,10 +53,9 @@ t.test("#ConvertFileS3", (t) => { // timeAchieved: 1287460462000, }; - const BaseS3Score = GetKTDataJSON("./s3/s3score.json"); t.test("Should import a valid S3 score", async (t) => { - const res = await cfile(BaseS3Score); + const res = await cfile(MockParsedS3Score); t.hasStrict( res, 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 1c651489d..a8de3b200 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 @@ -3,7 +3,7 @@ import db from "external/mongo/db"; import CreateLogCtx from "lib/logger/logger"; import t from "tap"; import ResetDBState from "test-utils/resets"; -import { barbScore } from "test-utils/test-data"; +import { MockBarbatosScore } from "test-utils/test-data"; import { ConverterIRBarbatos } from "./converter"; import { BarbatosScore } from "./types"; @@ -41,7 +41,7 @@ t.test("#ConverterIRBarbatos", (t) => { t.test("Should convert a BarbatosScore into a Dry Score", async (t) => { const res = await ConverterIRBarbatos( - barbScore, + MockBarbatosScore, { timeReceived: 10 }, "ir/barbatos", logger @@ -86,7 +86,7 @@ t.test("#ConverterIRBarbatos", (t) => { t.rejects( () => ConverterIRBarbatos( - deepmerge(barbScore, { song_id: 1000 }) as BarbatosScore, + deepmerge(MockBarbatosScore, { song_id: 1000 }) as BarbatosScore, { timeReceived: 10 }, "ir/barbatos", logger @@ -103,7 +103,7 @@ t.test("#ConverterIRBarbatos", (t) => { await db.songs.sdvx.remove({ id: 1 }); // force a song-chart desync t.rejects( - () => ConverterIRBarbatos(barbScore, { timeReceived: 10 }, "ir/barbatos", logger), + () => ConverterIRBarbatos(MockBarbatosScore, { timeReceived: 10 }, "ir/barbatos", logger), { message: /Song 1 \(sdvx\) has no parent song/u, } diff --git a/server/src/lib/score-import/import-types/ir/barbatos/parser.test.ts b/server/src/lib/score-import/import-types/ir/barbatos/parser.test.ts index f654fd838..16d9179d1 100644 --- a/server/src/lib/score-import/import-types/ir/barbatos/parser.test.ts +++ b/server/src/lib/score-import/import-types/ir/barbatos/parser.test.ts @@ -1,7 +1,7 @@ import CreateLogCtx from "lib/logger/logger"; import t from "tap"; import ResetDBState from "test-utils/resets"; -import { barbScore } from "test-utils/test-data"; +import { MockBarbatosScore } from "test-utils/test-data"; import { ParseBarbatosSingle } from "./parser"; const logger = CreateLogCtx(__filename); @@ -10,12 +10,12 @@ t.test("#ParseBarbatosSingle", (t) => { t.beforeEach(ResetDBState); t.test("Should return the score as a payload", (t) => { - const res = ParseBarbatosSingle(barbScore as unknown as Record, logger); + const res = ParseBarbatosSingle(MockBarbatosScore as unknown as Record, logger); t.hasStrict(res, { game: "sdvx", context: {}, - iterable: [barbScore], + iterable: [MockBarbatosScore], }); t.end(); diff --git a/server/src/lib/score-import/import-types/ir/fervidex-static/parser.test.ts b/server/src/lib/score-import/import-types/ir/fervidex-static/parser.test.ts index 3b1569558..46c840975 100644 --- a/server/src/lib/score-import/import-types/ir/fervidex-static/parser.test.ts +++ b/server/src/lib/score-import/import-types/ir/fervidex-static/parser.test.ts @@ -1,7 +1,7 @@ import CreateLogCtx from "lib/logger/logger"; import t from "tap"; import ResetDBState from "test-utils/resets"; -import { GetKTDataJSON } from "test-utils/test-data"; +import { FervidexStaticBase, GetKTDataJSON } from "test-utils/test-data"; import { ParseFervidexStatic } from "./parser"; const logger = CreateLogCtx(__filename); @@ -9,10 +9,8 @@ const logger = CreateLogCtx(__filename); t.test("#ParseFervidexStatic", (t) => { t.beforeEach(ResetDBState); - const ferStatic = GetKTDataJSON("./fervidex-static/base.json"); - t.test("Should parse static data from body", (t) => { - const res = ParseFervidexStatic(ferStatic, { model: "LDJ:J:B:A:2020092900" }, logger); + const res = ParseFervidexStatic(FervidexStaticBase, { model: "LDJ:J:B:A:2020092900" }, logger); t.strictSame(res.iterable, [ { diff --git a/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts b/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts index 0e6e9d5bf..6aa915961 100644 --- a/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts +++ b/server/src/lib/score-import/import-types/ir/fervidex/converter.test.ts @@ -4,7 +4,7 @@ import CreateLogCtx from "lib/logger/logger"; import t from "tap"; import ResetDBState from "test-utils/resets"; import { GetKTDataJSON, Testing511Song, Testing511SPA } from "test-utils/test-data"; -import { InternalFailure } from "../../../framework/common/converter-failures"; +import { InternalFailure, InvalidScoreFailure } from "../../../framework/common/converter-failures"; import { ConverterIRFervidex, SplitFervidexChartRef, @@ -81,7 +81,41 @@ t.test("#TachifyRandom", (t) => { t.end(); }); -const baseFervidexScore: FervidexScore = GetKTDataJSON("./fervidex/base.json"); +const baseFervidexScore: FervidexScore = { + "bad": 0, + "chart": "spa", + "clear_type": 1, + "combo_break": 6, + "custom": false, + "chart_sha256": "asdfasdf", + "entry_id": 1000, + "ex_score": 68, + "fast": 0, + "gauge": [ + 100, + 50 + ], + "ghost": [ + 0, + 2 + ], + "good": 0, + "great": 0, + "max_combo": 34, + "option": { + "gauge": "HARD", + "range": "SUDDEN_PLUS", + "style": "RANDOM" + }, + "pacemaker": { + "name": "", + "score": 363, + "type": "PACEMAKER_A" + }, + "pgreat": 34, + "poor": 6, + "slow": 0 +} const baseDryScore = { game: "iidx", @@ -208,7 +242,7 @@ t.test("#ConverterIRFervidex", (t) => { "ir/fervidex", logger ), - { message: /could not find chart/giu } + /could not find chart/giu ); t.end(); @@ -224,7 +258,7 @@ t.test("#ConverterIRFervidex", (t) => { "ir/fervidex", logger ), - { message: /Song 1 \(iidx\) has no parent song/giu } + /Song 1 \(iidx\) has no parent song/giu ); t.end(); @@ -239,7 +273,7 @@ t.test("#ConverterIRFervidex", (t) => { "ir/fervidex", logger ), - { message: /Invalid percent/giu } + /Invalid percent/giu ); t.end(); @@ -254,7 +288,7 @@ t.test("#ConverterIRFervidex", (t) => { "ir/fervidex", logger ), - { message: /Invalid value of gauge 150/giu } + /Invalid value of gauge 150./giu ); t.end(); diff --git a/server/src/lib/score-import/import-types/ir/fervidex/converter.ts b/server/src/lib/score-import/import-types/ir/fervidex/converter.ts index 97f9481aa..11529ff43 100644 --- a/server/src/lib/score-import/import-types/ir/fervidex/converter.ts +++ b/server/src/lib/score-import/import-types/ir/fervidex/converter.ts @@ -4,6 +4,7 @@ import { KTDataNotFoundFailure, } from "../../../framework/common/converter-failures"; import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils"; +import { IsNullishOrEmptyStr } from "utils/misc"; import { FindIIDXChartOnInGameIDVersion, FindIIDXChartWith2DXtraHash } from "utils/queries/charts"; import { FindSongOnID } from "utils/queries/songs"; import type { DryScore } from "../../../framework/common/types"; @@ -147,8 +148,8 @@ export const ConverterIRFervidex: ConverterFunction 100) { + // If gauge exists and is greater than 100 + // must be invalid + if ((gauge ?? 0) > 100) { throw new InvalidScoreFailure(`Invalid value of gauge ${gauge}.`); } diff --git a/server/src/lib/search/search.test.ts b/server/src/lib/search/search.test.ts index 649ca7c4f..bca76bcd7 100644 --- a/server/src/lib/search/search.test.ts +++ b/server/src/lib/search/search.test.ts @@ -44,12 +44,12 @@ t.test("#SearchUsersRegExp", (t) => { const res = await SearchUsersRegExp("zkldi"); t.equal(res.length, 1); - t.equal(res[0].usernameLowercase, "test_zkldi"); + t.equal(res[0]?.usernameLowercase, "test_zkldi"); const res2 = await SearchUsersRegExp("zk"); t.equal(res2.length, 1); - t.equal(res2[0].usernameLowercase, "test_zkldi"); + t.equal(res2[0]?.usernameLowercase, "test_zkldi"); const res3 = await SearchUsersRegExp("zkzdi"); diff --git a/server/src/main.ts b/server/src/main.ts index f1b3dc054..72ea9112c 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -38,7 +38,9 @@ async function RunOnInit() { await db["folder-chart-lookup"].findOne().then((r) => { // If there are no folder chart lookups, initialise them. if (!r) { - InitaliseFolderChartLookup(); + InitaliseFolderChartLookup().catch((err: unknown) => { + logger.error(`Failed to init folder-chart-lookup on first boot?`, { err }); + }); } }); @@ -55,7 +57,7 @@ async function RunOnInit() { } } -RunOnInit(); +void RunOnInit(); let instance: http.Server | https.Server; diff --git a/server/src/server/middleware/auth.ts b/server/src/server/middleware/auth.ts index a190beb99..69fa958cb 100644 --- a/server/src/server/middleware/auth.ts +++ b/server/src/server/middleware/auth.ts @@ -3,8 +3,9 @@ import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import { TachiConfig } from "lib/setup/config"; import { ALL_PERMISSIONS, UserAuthLevels } from "tachi-common"; -import { SplitAuthorizationHeader } from "utils/misc"; +import { IsNullishOrEmptyStr, SplitAuthorizationHeader } from "utils/misc"; import type { RequestHandler } from "express"; +import type { Session, SessionData } from "express-session"; import type { APIPermissions, APITokenDocument } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -35,10 +36,15 @@ export const SetRequestPermissions: RequestHandler = CreateSetRequestPermissions */ function CreateSetRequestPermissions(errorKeyName: string): RequestHandler { return async (req, res, next) => { - if (req.session.tachi?.user.id) { + // Types here are wrong. Sometimes req.session is not set. + // As such, we force an assertion. + + const maybeSession = req.session as (Partial & Session) | undefined; + + if (maybeSession?.tachi?.user.id !== undefined) { req[SYMBOL_TACHI_API_AUTH] = { - userID: req.session.tachi.user.id, - identifier: `Session-Key ${req.session.tachi.user.id}`, + userID: maybeSession.tachi.user.id, + identifier: `Session-Key ${maybeSession.tachi.user.id}`, token: null, permissions: ALL_PERMISSIONS, fromAPIClient: null, @@ -50,7 +56,7 @@ function CreateSetRequestPermissions(errorKeyName: string): RequestHandler { const header = req.header("Authorization"); // if no auth was attempted, default to the guest token. - if (!header) { + if (IsNullishOrEmptyStr(header)) { req[SYMBOL_TACHI_API_AUTH] = GuestToken; next(); return; @@ -124,7 +130,7 @@ export const RequirePermissions = }); } - if (!req[SYMBOL_TACHI_API_AUTH].userID) { + if (req[SYMBOL_TACHI_API_AUTH].userID === null) { return res.status(401).json({ success: false, description: `You are not authorised to perform this action.`, @@ -134,7 +140,7 @@ export const RequirePermissions = const missingPerms = []; for (const perm of perms) { - if (!req[SYMBOL_TACHI_API_AUTH]!.permissions[perm]) { + if (req[SYMBOL_TACHI_API_AUTH].permissions[perm] !== true) { missingPerms.push(perm); } } @@ -185,7 +191,7 @@ export const RequireNotGuest: RequestHandler = CreateRequireNotGuest("descriptio export const FervidexStyleRequireNotGuest: RequestHandler = CreateRequireNotGuest("error"); export const RejectIfBanned: RequestHandler = async (req, res, next) => { - if (req[SYMBOL_TACHI_API_AUTH].userID) { + if (req[SYMBOL_TACHI_API_AUTH].userID !== null) { const isBanned = await db.users.findOne({ id: req[SYMBOL_TACHI_API_AUTH].userID!, authLevel: UserAuthLevels.BANNED, diff --git a/server/src/server/middleware/multer-upload.ts b/server/src/server/middleware/multer-upload.ts index 71d7423df..297ac8a78 100644 --- a/server/src/server/middleware/multer-upload.ts +++ b/server/src/server/middleware/multer-upload.ts @@ -8,7 +8,8 @@ import type { integer } from "tachi-common"; const defaultLogger = CreateLogCtx(__filename); -export const DefaultMulterUpload = multer({ limits: { fileSize: 1024 * 1024 * 16 } }); // 16MB +// 16MB +export const DefaultMulterUpload = multer({ limits: { fileSize: 1024 * 1024 * 16 } }); export const CreateMulterSingleUploadMiddleware = ( fieldName: string, @@ -44,6 +45,10 @@ export const CreateMulterSingleUploadMiddleware = ( }); } + // CRITICALLY IMPORTANT LINE OF CODE + // THINGS DEALING WITH FILE UPLOADS **DO NOT** MOUNT SAFE-BODY OTHERWISE. + req.safeBody = req.body as Record; + next(); }); }; diff --git a/server/src/server/middleware/request-logger.ts b/server/src/server/middleware/request-logger.ts index 11b8c7c58..bf09023db 100644 --- a/server/src/server/middleware/request-logger.ts +++ b/server/src/server/middleware/request-logger.ts @@ -17,16 +17,9 @@ const ResJsonInteceptor = (res: Response, json: Response["json"]) => (content: u }; export const RequestLoggerMiddleware: RequestHandler = (req, res, next) => { - // I'm not really a fan of this style of omission - but it works. - - // we **KNOW** for certain that there are only two endpoints where a password is - // sent to us - /register and /auth. - // and both of those use `password` as a key. - // still, i don't like it. - const safeBody: Record = {}; - for (const [k, v] of Object.entries(req.body)) { + for (const [k, v] of Object.entries(req.safeBody)) { // Keys that start with ! are private information, // and should not ever be logged. if (k.startsWith("!")) { diff --git a/server/src/server/router/api/v1/admin/router.ts b/server/src/server/router/api/v1/admin/router.ts index 40bb3468f..0c70414c3 100644 --- a/server/src/server/router/api/v1/admin/router.ts +++ b/server/src/server/router/api/v1/admin/router.ts @@ -11,17 +11,18 @@ import prValidate from "server/middleware/prudence-validate"; import { UserAuthLevels } from "tachi-common"; import { RecalcAllScores, UpdateAllPBs } from "utils/calculations/recalc-scores"; import { RecalcSessions } from "utils/calculations/recalc-sessions"; +import { IsValidPlaytype } from "utils/misc"; import DestroyUserGamePlaytypeData from "utils/reset-state/destroy-ugpt"; import { GetUserWithID } from "utils/user"; import type { RequestHandler } from "express"; -import type { Game } from "tachi-common"; +import type { Game, integer, Playtype } from "tachi-common"; const logger = CreateLogCtx(__filename); const router: Router = Router({ mergeParams: true }); const RequireAdminLevel: RequestHandler = async (req, res, next) => { - if (!req[SYMBOL_TACHI_API_AUTH].userID) { + if (req[SYMBOL_TACHI_API_AUTH].userID === null) { return res.status(401).json({ success: false, description: `You are not authenticated.`, @@ -75,20 +76,26 @@ router.post( noReset: p.optional("boolean"), }), (req, res) => { + const body = req.safeBody as { + logLevel: "crit" | "debug" | "error" | "info" | "severe" | "verbose" | "warn"; + duration?: integer; + noReset?: boolean; + }; + const logLevel = GetLogLevel(); - ChangeRootLogLevel(req.body.logLevel); + ChangeRootLogLevel(body.logLevel); - const duration = req.body.duration ?? 60; + const duration = body.duration ?? 60; if (currentLogLevelTimer) { logger.verbose(`Removing last timer to reset log level to ${LOG_LEVEL}.`); clearTimeout(currentLogLevelTimer); } - logger.info(`Log level has been changed to ${req.body.level}.`); + logger.info(`Log level has been changed to ${body.logLevel}.`); - if (!req.body.noReset) { + if (body.noReset !== true) { logger.info(`This will reset to "${LOG_LEVEL}" level in ${duration} minutes.`); currentLogLevelTimer = setTimeout(() => { @@ -100,7 +107,7 @@ router.post( return res.status(200).json({ success: true, - description: `Changed log level from ${logLevel} to ${req.body.logLevel}.`, + description: `Changed log level from ${logLevel} to ${body.logLevel}.`, body: {}, }); } @@ -121,7 +128,12 @@ router.post( filter: "*object", }), async (req, res) => { - await UpdateAllPBs(req.body.userIDs, req.body.filter); + const body = req.safeBody as { + userIDs?: Array; + filter?: object; + }; + + await UpdateAllPBs(body.userIDs, body.filter); return res.status(200).json({ success: true, @@ -139,7 +151,9 @@ router.post( * @name POST /api/v1/admin/delete-score */ router.post("/delete-score", prValidate({ scoreID: "string" }), async (req, res) => { - const score = await db.scores.findOne({ scoreID: req.body.scoreID }); + const body = req.safeBody as { scoreID: string }; + + const score = await db.scores.findOne({ scoreID: body.scoreID }); if (!score) { return res.status(404).json({ @@ -171,14 +185,29 @@ router.post( prValidate({ userID: p.isInteger, game: p.isIn(TachiConfig.GAMES), - playtype: "string", // lazy + playtype: (self, parent) => { + if (typeof self !== "string") { + return "Expected a string for a playtype."; + } + + if (!IsValidPlaytype(parent.game as Game, self)) { + return `Invalid playtype of ${self} for game ${parent.game as Game}.`; + } + + return true; + }, }), async (req, res) => { - const { userID, game, playtype } = req.body; + const { userID, game, playtype } = req.safeBody as { + userID: integer; + game: Game; + playtype: Playtype; + }; const ugpt = await db["game-stats"].findOne({ userID, game, + playtype, }); @@ -211,8 +240,12 @@ router.post( "/destroy-chart", prValidate({ chartID: "string", game: p.isIn(TachiConfig.GAMES) }), async (req, res) => { - const game: Game = req.body.game; - const chartID: string = req.body.chartID; + const body = req.safeBody as { + game: Game; + chartID: string; + }; + + const { game, chartID } = body; const scores = await db.scores.find({ chartID, @@ -242,7 +275,7 @@ router.post( * @name POST /api/v1/admin/recalc */ router.post("/recalc", async (req, res) => { - const filter = req.body ?? {}; + const filter = req.safeBody; await RecalcAllScores(filter); diff --git a/server/src/server/router/api/v1/auth/router.ts b/server/src/server/router/api/v1/auth/router.ts index 3b7685b5a..234e3c940 100644 --- a/server/src/server/router/api/v1/auth/router.ts +++ b/server/src/server/router/api/v1/auth/router.ts @@ -29,6 +29,7 @@ import { GetUserCaseInsensitive, GetUserPrivateInfo, GetUserWithID, + GetUserWithIDGuaranteed, } from "utils/user"; import type { integer } from "tachi-common"; @@ -60,17 +61,23 @@ router.post( "verbose" ), async (req, res) => { - if (req.session.tachi?.user.id) { + if (req.session.tachi?.user.id !== undefined) { // Dual logins should destroy the users session and recreate it. req.session.tachi = undefined; } - logger.verbose(`Received login request with username ${req.body.username} (${req.ip})`); + const body = req.safeBody as { + username: string; + "!password": string; + captcha: string; + }; + + logger.verbose(`Received login request with username ${body.username} (${req.ip})`); /* istanbul ignore next */ if (Environment.nodeEnv === "production" || Environment.nodeEnv === "staging") { logger.verbose("Validating captcha..."); - const validCaptcha = await ValidateCaptcha(req.body.captcha, req.socket.remoteAddress); + const validCaptcha = await ValidateCaptcha(body.captcha, req.socket.remoteAddress); if (!validCaptcha) { logger.verbose("Captcha failed."); @@ -85,10 +92,10 @@ router.post( logger.warn("Skipped captcha check because not in production."); } - const requestedUser = await GetUserCaseInsensitive(req.body.username); + const requestedUser = await GetUserCaseInsensitive(body.username); if (!requestedUser) { - logger.verbose(`Invalid username for login ${req.body.username}.`); + logger.verbose(`Invalid username for login ${body.username}.`); return res.status(404).json({ success: false, description: `This user does not exist.`, @@ -111,7 +118,7 @@ router.post( }); } - const passwordMatch = await PasswordCompare(req.body["!password"], privateInfo.password); + const passwordMatch = await PasswordCompare(body["!password"], privateInfo.password); if (!passwordMatch) { logger.verbose("Invalid password provided."); @@ -178,12 +185,27 @@ router.post( "verbose" ), async (req, res) => { - logger.verbose(`received register request with username ${req.body.username} (${req.ip})`); + const body = req.safeBody as { + username: string; + "!password": string; + email: string; + inviteCode?: string; + captcha: string; + }; + + if (body.inviteCode === undefined && ServerConfig.INVITE_CODE_CONFIG) { + return res.status(400).json({ + success: false, + description: `No invite code given, yet the server uses invites.`, + }); + } + + logger.verbose(`received register request with username ${body.username} (${req.ip})`); /* istanbul ignore next */ if (Environment.nodeEnv === "production" || Environment.nodeEnv === "staging") { logger.verbose("Validating captcha..."); - const validCaptcha = await ValidateCaptcha(req.body.captcha, req.socket.remoteAddress); + const validCaptcha = await ValidateCaptcha(body.captcha, req.socket.remoteAddress); if (!validCaptcha) { logger.verbose("Captcha failed."); @@ -198,17 +220,17 @@ router.post( logger.warn("Skipped captcha check because not in production."); } - const existingUser = await GetUserCaseInsensitive(req.body.username); + const existingUser = await GetUserCaseInsensitive(body.username); if (existingUser) { - logger.verbose(`Invalid username ${req.body.username}, already in use.`); + logger.verbose(`Invalid username ${body.username}, already in use.`); return res.status(409).json({ success: false, description: "This username is already in use.", }); } - const existingEmail = await CheckIfEmailInUse(req.body.email); + const existingEmail = await CheckIfEmailInUse(body.email); if (existingEmail) { logger.info(`User attempted to sign up with email that was already in use.`); @@ -226,7 +248,7 @@ router.post( if (ServerConfig.INVITE_CODE_CONFIG) { const inviteCodeDoc = await db.invites.findOneAndUpdate( { - code: req.body.inviteCode, + code: body.inviteCode, consumed: false, }, { @@ -239,7 +261,7 @@ router.post( ); if (!inviteCodeDoc) { - logger.info(`Invalid invite code given: ${req.body.inviteCode}.`); + logger.info(`Invalid invite code given: ${body.inviteCode}.`); return res.status(401).json({ success: false, description: `This invite code is not valid.`, @@ -252,52 +274,48 @@ router.post( // if we get to this point, We're good to create the user. const { newUser, newSettings } = await AddNewUser( - req.body.username, - req.body["!password"], - req.body.email, + body.username, + body["!password"], + body.email, userID ); - if (!newUser) { - throw new Error("AddNewUser failed to create a user."); - } - hasInsertedUserID = newUser.id; // re-fetch the user like this so we guaranteeably omit the private fields. - const user = await GetUserWithID(newUser.id); + const user = await GetUserWithIDGuaranteed(newUser.id); - MountAuthCookie(req, user!, newSettings); + MountAuthCookie(req, user, newSettings); const resetEmailCode = Random20Hex(); await db["verify-email-codes"].insert({ code: resetEmailCode, userID, - email: req.body.email, + email: body.email, }); - const { text, html } = EmailFormatVerifyEmail(user!.username, resetEmailCode); + const { text, html } = EmailFormatVerifyEmail(user.username, resetEmailCode); - SendEmail(req.body.email, "Email Verification", html, text); + void SendEmail(body.email, "Email Verification", html, text); return res.status(200).json({ success: true, - description: `Successfully created account ${req.body.username}!`, + description: `Successfully created account ${body.username}!`, body: user, }); } catch (err) { - logger.error(`Bailed on user creation ${req.body.username}.`, { err }); + logger.error(`Bailed on user creation ${body.username}.`, { err }); - if (ServerConfig.INVITE_CODE_CONFIG) { - await ReinstateInvite(req.body.inviteCode); + if (ServerConfig.INVITE_CODE_CONFIG && body.inviteCode !== undefined) { + await ReinstateInvite(body.inviteCode); } if (hasInsertedUserID !== null) { logger.warn( - `Removing user ${req.body.username} (#${hasInsertedUserID}), as their document was created, but creation still failed.` + `Removing user ${body.username} (#${hasInsertedUserID}), as their document was created, but creation still failed.` ); - await db.users.remove({ username: req.body.username }); + await db.users.remove({ username: body.username }); await db["user-settings"].remove({ userID: hasInsertedUserID }); await db["user-private-information"].remove({ userID: hasInsertedUserID }); } @@ -326,8 +344,12 @@ router.post( code: "string", }), async (req, res) => { + const body = req.safeBody as { + code: string; + }; + const code = await db["verify-email-codes"].findOne({ - code: req.body.code, + code: body.code, }); if (!code) { @@ -338,7 +360,7 @@ router.post( } await db["verify-email-codes"].remove({ - code: req.body.code, + code: body.code, }); return res.status(200).json({ @@ -387,7 +409,7 @@ router.post("/resend-verify-email", HyperAggressiveRateLimitMiddleware, async (r const { text, html } = EmailFormatVerifyEmail(user.username, verifyInfo.code); - SendEmail(verifyInfo.email, "Email Verification", html, text); + void SendEmail(verifyInfo.email, "Email Verification", html, text); }); /** @@ -395,7 +417,7 @@ router.post("/resend-verify-email", HyperAggressiveRateLimitMiddleware, async (r * @name POST /api/v1/auth/logout */ router.post("/logout", (req, res) => { - if (!req.session.tachi?.user.id) { + if (req.session.tachi?.user.id === undefined) { return res.status(409).json({ success: false, description: `You are not logged in.`, @@ -431,7 +453,11 @@ router.post( }); } - logger.debug(`received password reset request for ${req.body.email}.`); + const body = req.safeBody as { + email: string; + }; + + logger.debug(`received password reset request for ${body.email}.`); // For timing attack and infosec reasons, we can't do anything but **immediately** return here. res.status(202).json({ @@ -441,7 +467,7 @@ router.post( }); const userPrivateInfo = await db["user-private-information"].findOne({ - email: req.body.email, + email: body.email, }); if (userPrivateInfo) { @@ -466,10 +492,10 @@ router.post( const { html, text } = EmailFormatResetPassword(user.username, code, req.ip); - SendEmail(userPrivateInfo.email, "Reset Password", html, text); + void SendEmail(userPrivateInfo.email, "Reset Password", html, text); } else { logger.info( - `Silently rejected password reset request for ${req.body.email}, as no user has this email.` + `Silently rejected password reset request for ${body.email}, as no user has this email.` ); } } @@ -492,8 +518,13 @@ router.post( "!password": ValidatePassword, }), async (req, res) => { + const body = req.safeBody as { + code: string; + "!password": string; + }; + const code = await db["password-reset-codes"].findOneAndDelete({ - code: req.body.code, + code: body.code, }); if (!code) { @@ -503,7 +534,7 @@ router.post( }); } - const encryptedPassword = await HashPassword(req.body["!password"]); + const encryptedPassword = await HashPassword(body["!password"]); await db["user-private-information"].update( { diff --git a/server/src/server/router/api/v1/clients/middleware.test.ts b/server/src/server/router/api/v1/clients/middleware.test.ts index 7af19c1f6..6a6ee478b 100644 --- a/server/src/server/router/api/v1/clients/middleware.test.ts +++ b/server/src/server/router/api/v1/clients/middleware.test.ts @@ -12,6 +12,7 @@ t.test("#GetClientFromID", (t) => { params: { clientID: "OAUTH2_CLIENT_ID", }, + [SYMBOL_TACHI_DATA]: {} }); t.strictSame( @@ -27,7 +28,7 @@ t.test("#GetClientFromID", (t) => { apiKeyTemplate: null, apiKeyFilename: null, }, - "Should assign clientDoc with secret ommitted." + "Should assign clientDoc with secret omitted." ); t.end(); @@ -38,6 +39,7 @@ t.test("#GetClientFromID", (t) => { params: { clientID: "NONSENSE", }, + [SYMBOL_TACHI_DATA]: {} }); t.equal(res.statusCode, 404); @@ -58,11 +60,12 @@ t.test("#RequireOwnershipOfClient", (t) => { t.test("Should return 401 if the user has no authentication.", async (t) => { const { res } = await expMiddlewareMock(RequireOwnershipOfClient, { - body: { + safeBody: { __terribleHackOauth2ClientDoc: { author: 1, }, }, + [SYMBOL_TACHI_DATA]: {}, session: {}, }); @@ -73,7 +76,7 @@ t.test("#RequireOwnershipOfClient", (t) => { t.test("Should return 403 if the user does not own this client.", async (t) => { const { res } = await expMiddlewareMock(RequireOwnershipOfClient, { - body: { + safeBody: { __terribleHackOauth2ClientDoc: { author: 1, }, @@ -85,6 +88,7 @@ t.test("#RequireOwnershipOfClient", (t) => { }, }, }, + [SYMBOL_TACHI_DATA]: {}, }); t.equal(res.statusCode, 403); @@ -94,7 +98,7 @@ t.test("#RequireOwnershipOfClient", (t) => { t.test("Should continue if this is their session.", async (t) => { const { res } = await expMiddlewareMock(RequireOwnershipOfClient, { - body: { + safeBody: { __terribleHackOauth2ClientDoc: { author: 1, }, @@ -106,6 +110,7 @@ t.test("#RequireOwnershipOfClient", (t) => { }, }, }, + [SYMBOL_TACHI_DATA]: {}, }); t.equal(res.statusCode, 200); diff --git a/server/src/server/router/api/v1/clients/middleware.ts b/server/src/server/router/api/v1/clients/middleware.ts index 0476f3562..3fc5ba469 100644 --- a/server/src/server/router/api/v1/clients/middleware.ts +++ b/server/src/server/router/api/v1/clients/middleware.ts @@ -1,5 +1,4 @@ import db from "external/mongo/db"; -import { SYMBOL_TACHI_DATA } from "lib/constants/tachi"; import { Environment } from "lib/setup/config"; import { AssignToReqTachiData, GetTachiData } from "utils/req-tachi-data"; import type { RequestHandler } from "express"; @@ -37,10 +36,13 @@ export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => { // request. To hack around this for testing, we perform this hack. // There's an open issue for this here: https://github.com/i-like-robots/express-request-mock/issues/19 /* istanbul ignore next */ - if (Environment.nodeEnv === "test" && req.body.__terribleHackOauth2ClientDoc) { + if ( + Environment.nodeEnv === "test" && + (req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument | undefined) + ) { // obviously a glaring hack and security flaw - this only applies // in testing. - client = req.body.__terribleHackOauth2ClientDoc; + client = req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument; } else { client = GetTachiData(req, "apiClientDoc"); } diff --git a/server/src/server/router/api/v1/clients/router.ts b/server/src/server/router/api/v1/clients/router.ts index 38466a02d..6bb7f6b4b 100644 --- a/server/src/server/router/api/v1/clients/router.ts +++ b/server/src/server/router/api/v1/clients/router.ts @@ -1,7 +1,6 @@ import { GetClientFromID, RequireOwnershipOfClient } from "./middleware"; import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TACHI_DATA } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import { ServerConfig } from "lib/setup/config"; import p from "prudence"; @@ -90,6 +89,15 @@ router.post( }); } + const body = req.safeBody as { + name: string; + redirectUri: string | null; + webhookUri: string | null; + apiKeyTemplate: string | null; + apiKeyFilename: string | null; + permissions: Array; + }; + const existingClients = await db["api-clients"].find({ author: req.session.tachi.user.id, }); @@ -105,7 +113,7 @@ router.post( }); } - const permissions = DedupeArr(req.body.permissions); + const permissions = DedupeArr(body.permissions); if (permissions.length === 0) { return res.status(400).json({ @@ -114,14 +122,14 @@ router.post( }); } - if (req.body.redirectUri !== null && !IsValidURL(req.body.redirectUri)) { + if (body.redirectUri !== null && !IsValidURL(body.redirectUri)) { return res.status(400).json({ success: false, description: `Invalid Redirect URL.`, }); } - if (req.body.webhookUri !== null && !IsValidURL(req.body.webhookUri)) { + if (body.webhookUri !== null && !IsValidURL(body.webhookUri)) { return res.status(400).json({ success: false, description: `Invalid Webhook URL.`, @@ -135,19 +143,19 @@ router.post( clientID, clientSecret, requestedPermissions: permissions, - name: req.body.name, + name: body.name, author: req.session.tachi.user.id, - redirectUri: req.body.redirectUri, - webhookUri: req.body.webhookUri ?? null, - apiKeyFilename: req.body.apiKeyFilename ?? null, - apiKeyTemplate: req.body.apiKeyTemplate ?? null, + redirectUri: body.redirectUri, + webhookUri: body.webhookUri ?? null, + apiKeyFilename: body.apiKeyFilename ?? null, + apiKeyTemplate: body.apiKeyTemplate ?? null, }; await db["api-clients"].insert(clientDoc); logger.info( `User ${FormatUserDoc(req.session.tachi.user)} created a new API Client ${ - req.body.name + body.name } (${clientID}).` ); @@ -232,11 +240,20 @@ router.patch( }), }), async (req, res) => { + const body = req.safeBody as { + name?: string; + redirectUri?: string | null; + webhookUri?: string | null; + apiKeyTemplate?: string | null; + apiKeyFilename?: string | null; + permissions?: Array; + }; + const client = GetTachiData(req, "apiClientDoc"); - DeleteUndefinedProps(req.body); + DeleteUndefinedProps(req.safeBody); - if (Object.keys(req.body).length === 0) { + if (Object.keys(req.safeBody).length === 0) { return res.status(400).json({ success: false, description: `No changes to make.`, @@ -248,12 +265,12 @@ router.patch( clientID: client.clientID, }, { - $set: req.body, + $set: req.safeBody, } ); logger.info( - `API Client ${client.name} (${client.clientID}) has been renamed to ${req.body.name}.` + `API Client ${client.name} (${client.clientID}) has been renamed to ${body.name}.` ); return res.status(200).json({ diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts index 122d4e347..a93120920 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/goals/router.test.ts @@ -17,7 +17,7 @@ const LoadLazySampleData = async () => { await db.goals.insert(IIDXSPMilestoneGoals); await db["goal-subs"].insert([ ...IIDXSPMilestoneGoalSubs, - dm(IIDXSPMilestoneGoalSubs[0], { + dm(IIDXSPMilestoneGoalSubs[0]!, { userID: 2, }), ] as GoalSubscriptionDocument[]); diff --git a/server/src/server/router/api/v1/games/_game/_playtype/targets/router.test.ts b/server/src/server/router/api/v1/games/_game/_playtype/targets/router.test.ts index 9585382a8..3acf71415 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/targets/router.test.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/targets/router.test.ts @@ -2,6 +2,7 @@ import dm from "deepmerge"; import db from "external/mongo/db"; import { GoalSubscriptionDocument } from "tachi-common"; import t from "tap"; +import { mkFakeGoal, mkFakeGoalSub } from "test-utils/misc"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; import { HC511UserGoal } from "test-utils/test-data"; @@ -9,18 +10,19 @@ import { HC511UserGoal } from "test-utils/test-data"; t.test("GET /api/v1/games/:game/:playtype/targets/recently-achieved", (t) => { t.beforeEach(ResetDBState); - // mutate - function m(partial: Partial): GoalSubscriptionDocument { - return dm(HC511UserGoal, partial); - } - t.test("Should return some recently achieved goals.", async (t) => { + await db.goals.insert([ + mkFakeGoal({ goalID: "achieved" }), + mkFakeGoal({ goalID: "achieved_more_recently" }), + mkFakeGoal({ goalID: "achieved_instantly" }), + ]) + await db["goal-subs"].insert([ // not achieved HC511UserGoal, - m({ goalID: "achieved", achieved: true, timeAchieved: 1000 }), - m({ goalID: "achieved_more_recently", achieved: true, timeAchieved: 2000 }), - m({ + mkFakeGoalSub({ goalID: "achieved", achieved: true, timeAchieved: 1000 }), + mkFakeGoalSub({ goalID: "achieved_more_recently", achieved: true, timeAchieved: 2000 }), + mkFakeGoalSub({ goalID: "achieved_instantly", achieved: true, timeAchieved: 1000, @@ -48,25 +50,27 @@ t.test("GET /api/v1/games/:game/:playtype/targets/recently-achieved", (t) => { t.test("GET /api/v1/games/:game/:playtype/targets/recently-raised", (t) => { t.beforeEach(ResetDBState); - // mutate - function m(partial: Partial): GoalSubscriptionDocument { - return dm(HC511UserGoal, partial); - } - t.test("Should return some recently interacted goals.", async (t) => { + await db.goals.insert([ + mkFakeGoal({ goalID: "interacted" }), + mkFakeGoal({ goalID: "interacted_more_recently" }), + mkFakeGoal({ goalID: "achieved" }), + mkFakeGoal({ goalID: "achieved_instantly" }), + ]); + await db["goal-subs"].insert([ // not achieved HC511UserGoal, - m({ goalID: "interacted", achieved: false, lastInteraction: 1000 }), + mkFakeGoalSub({ goalID: "interacted", achieved: false, lastInteraction: 1000 }), // happened more recently - m({ goalID: "interacted_more_recently", achieved: false, lastInteraction: 2000 }), + mkFakeGoalSub({ goalID: "interacted_more_recently", achieved: false, lastInteraction: 2000 }), // shouldnt be included -- just recently-raised. - m({ + mkFakeGoalSub({ goalID: "achieved", achieved: true, lastInteraction: 1000, }), - m({ + mkFakeGoalSub({ goalID: "achieved_instantly", achieved: true, lastInteraction: 1000, diff --git a/server/src/server/router/api/v1/import/router.ts b/server/src/server/router/api/v1/import/router.ts index b2a8f4839..ca45e5baf 100644 --- a/server/src/server/router/api/v1/import/router.ts +++ b/server/src/server/router/api/v1/import/router.ts @@ -58,7 +58,7 @@ router.post( }); } - const importType = req.body.importType as FileUploadImportTypes; + const importType = req.safeBody.importType as FileUploadImportTypes; const userIntent = !!req.header("X-User-Intent"); @@ -70,11 +70,11 @@ router.post( userID: req[SYMBOL_TACHI_API_AUTH].userID!, userIntent, importType, - parserArguments: [req.file, req.body], + parserArguments: [req.file, req.safeBody], }; // Fire the score import, but make no guarantees about its state. - MakeScoreImport(job); + void MakeScoreImport(job); return res.status(202).json({ success: true, @@ -92,7 +92,7 @@ router.post( req[SYMBOL_TACHI_API_AUTH].userID!, userIntent, importType, - [req.file, req.body] + [req.file, req.safeBody] ); return res.status(importResponse.statusCode).json(importResponse.body); @@ -114,7 +114,7 @@ router.post( { allowExcessKeys: true } ), async (req, res) => { - const importType = req.body.importType as APIImportTypes; + const importType = req.safeBody.importType as APIImportTypes; const importID = Random20Hex(); diff --git a/server/src/server/router/api/v1/oauth/router.ts b/server/src/server/router/api/v1/oauth/router.ts index 2f7a81a98..7f894ba80 100644 --- a/server/src/server/router/api/v1/oauth/router.ts +++ b/server/src/server/router/api/v1/oauth/router.ts @@ -31,8 +31,16 @@ router.post( code: "string", }), async (req, res) => { + const body = req.safeBody as { + client_id: string; + client_secret: string; + grant_type: "authorization_code"; + redirect_uri: string; + code: string; + }; + const client = await db["api-clients"].findOne({ - clientID: req.body.client_id, + clientID: body.client_id, }); if (!client) { @@ -42,7 +50,7 @@ router.post( }); } - if (client.clientSecret !== req.body.client_secret) { + if (client.clientSecret !== body.client_secret) { return res.status(403).json({ success: false, description: `Invalid secret.`, @@ -51,14 +59,14 @@ router.post( // I honest to god have no idea what the point of this check is // but it's part of the oauth spec. - if (client.redirectUri !== req.body.redirect_uri) { + if (client.redirectUri !== body.redirect_uri) { return res.status(400).json({ success: false, description: `This redirect_uri does not match with your registered client redirect_uri ${client.redirectUri}.`, }); } - const codeDoc = await db["oauth2-auth-codes"].findOne({ code: req.body.code }); + const codeDoc = await db["oauth2-auth-codes"].findOne({ code: body.code }); if (!codeDoc) { return res.status(404).json({ @@ -68,7 +76,7 @@ router.post( } // don't let people auth with the same code multiple times. - await db["oauth2-auth-codes"].remove({ code: req.body.code }); + await db["oauth2-auth-codes"].remove({ code: body.code }); const apiDoc = { userID: codeDoc.userID, diff --git a/server/src/server/router/api/v1/scores/_scoreID/router.ts b/server/src/server/router/api/v1/scores/_scoreID/router.ts index c6027a4a3..ee75bac93 100644 --- a/server/src/server/router/api/v1/scores/_scoreID/router.ts +++ b/server/src/server/router/api/v1/scores/_scoreID/router.ts @@ -1,7 +1,6 @@ import { GetScoreFromParam, RequireOwnershipOfScoreOrAdmin } from "./middleware"; import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TACHI_DATA } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import { DeleteScore } from "lib/score-mutation/delete-scores"; import p from "prudence"; @@ -26,7 +25,7 @@ router.use(GetScoreFromParam); router.get("/", async (req, res) => { const score = GetTachiData(req, "scoreDoc"); - if (req.query.getRelated) { + if (req.query.getRelated !== undefined) { const [user, chart, song] = await Promise.all([ GetUserWithID(score.userID), db.charts[score.game].findOne({ chartID: score.chartID }), @@ -88,16 +87,21 @@ router.patch( highlight: "*boolean", }), async (req, res) => { + const body = req.safeBody as { + comment?: string | null; + highlight?: boolean; + }; + const score = GetTachiData(req, "scoreDoc"); const modifyOption: ModifiableScoreProps = {}; - if (req.body.comment !== undefined) { - modifyOption.comment = req.body.comment; + if (body.comment !== undefined) { + modifyOption.comment = body.comment; } - if (req.body.highlight !== undefined) { - modifyOption.highlight = req.body.highlight; + if (body.highlight !== undefined) { + modifyOption.highlight = body.highlight; } if (Object.keys(modifyOption).length === 0) { @@ -145,11 +149,16 @@ router.patch( router.delete( "/", RequireOwnershipOfScoreOrAdmin, + prValidate({ blacklist: "*boolean" }), RequirePermissions("delete_score"), async (req, res) => { + const body = req.safeBody as { + blacklist?: boolean; + }; + const score = GetTachiData(req, "scoreDoc"); - await DeleteScore(score, !!req.body.blacklist); + await DeleteScore(score, body.blacklist); return res.status(200).json({ success: true, diff --git a/server/src/server/router/api/v1/sessions/_sessionID/router.ts b/server/src/server/router/api/v1/sessions/_sessionID/router.ts index 899947e54..681b2332c 100644 --- a/server/src/server/router/api/v1/sessions/_sessionID/router.ts +++ b/server/src/server/router/api/v1/sessions/_sessionID/router.ts @@ -87,16 +87,16 @@ router.patch( const updateExp: ModifiableSessionProps = {}; - if (req.body.name) { - updateExp.name = req.body.name as string; + if (req.safeBody.name) { + updateExp.name = req.safeBody.name as string; } - if (req.body.desc) { - updateExp.desc = req.body.desc as string; + if (req.safeBody.desc) { + updateExp.desc = req.safeBody.desc as string; } - if (typeof req.body.highlight === "boolean") { - updateExp.highlight = req.body.highlight as boolean; + if (typeof req.safeBody.highlight === "boolean") { + updateExp.highlight = req.safeBody.highlight; } if (Object.keys(updateExp).length === 0) { diff --git a/server/src/server/router/api/v1/status/router.ts b/server/src/server/router/api/v1/status/router.ts index e5d8afc0f..00081c8b6 100644 --- a/server/src/server/router/api/v1/status/router.ts +++ b/server/src/server/router/api/v1/status/router.ts @@ -45,8 +45,8 @@ router.get("/", (req, res) => { router.post("/", (req, res) => { let echo; - if (req.body.echo && typeof req.body.echo === "string") { - echo = req.body.echo; + if (req.safeBody.echo && typeof req.safeBody.echo === "string") { + echo = req.safeBody.echo; } return res.status(200).json({ diff --git a/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts b/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts index e0366649a..1b520e6ce 100644 --- a/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts +++ b/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts @@ -1,7 +1,6 @@ import { RequireSelfRequestFromUser } from "../middleware"; import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TACHI_DATA } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import p from "prudence"; import prValidate from "server/middleware/prudence-validate"; @@ -55,7 +54,13 @@ router.post( clientID: "*string", }), async (req, res) => { - if (req.body.clientID && req.body.permissions) { + const body = req.safeBody as { + permissions?: Array; + identifier?: string; + clientID?: string; + }; + + if (body.clientID !== undefined && body.permissions) { return res.status(400).json({ success: false, description: `Cannot use ClientID creation and permissions creation at the same time!`, @@ -69,10 +74,10 @@ router.post( let identifier: string; let fromAPIClient = null; - if (req.body.clientID) { + if (body.clientID !== undefined) { const client = await db["api-clients"].findOne( { - clientID: req.body.clientID, + clientID: body.clientID, }, { projection: { @@ -108,9 +113,9 @@ router.post( logger.info( `Creating API Key for ${FormatUserDoc(user)} from ${client.name} specification.` ); - } else if (req.body.permissions) { - permissions = req.body.permissions; - identifier = req.body.identifier ?? "Custom Token"; + } else if (body.permissions) { + permissions = body.permissions; + identifier = body.identifier ?? "Custom Token"; logger.info( `Creating API Key for ${FormatUserDoc(user)} with ${permissions.join(", ")}.` @@ -144,39 +149,4 @@ router.post( } ); -/** - * Delete this token. - * - * @name DELETE /api/v1/users/:userID/api-token/:token - */ -router.delete("/:token", async (req, res) => { - const user = GetTachiData(req, "requestedUser"); - - logger.info( - `received request from ${FormatUserDoc(user)} to delete token ${req.params.token}.` - ); - - const token = await db["api-tokens"].findOne({ - token: req.params.token, - userID: user.id, - }); - - if (!token) { - return res.status(404).json({ - success: false, - description: `This key does not exist.`, - }); - } - - await db["api-tokens"].remove({ token: req.params.token }); - - logger.info(`Deleted ${req.params.token}, which belonged to ${FormatUserDoc(user)}.`); - - return res.status(200).json({ - success: true, - description: `Removed Token.`, - body: {}, - }); -}); - export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts index e35ddb83d..df9d5310f 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/folders/_folderID/router.test.ts @@ -1,6 +1,6 @@ import deepmerge from "deepmerge"; import db from "external/mongo/db"; -import { FolderDocument, ScoreDocument } from "tachi-common"; +import { ChartDocument, FolderDocument, ScoreDocument, SongDocument } from "tachi-common"; import t from "tap"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; @@ -65,8 +65,8 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeli await db.songs.iidx.remove({}); await db.charts.iidx.remove({}); await db["folder-chart-lookup"].remove({}); - await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json")); - await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json")); + await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json") as Array>); + await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array>); await CreateFolderChartLookup(folder, true); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts index 753c4ee69..1404ae221 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts @@ -1,5 +1,5 @@ import db from "external/mongo/db"; -import { PBScoreDocument } from "tachi-common"; +import { ChartDocument, PBScoreDocument } from "tachi-common"; import t from "tap"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; @@ -89,16 +89,22 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs", (t) => { t.test("Should search a user's personal bests.", async (t) => { const mockPBs: PBScoreDocument[] = []; - const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json"); + const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array; for (let i = 0; i < 200; i++) { + let chart = charts[i]; + + if (!chart) { + return t.fail(`Not enough charts in tachi-charts-iidx.json to mock pb data? Failed at index ${i}.`); + } + mockPBs.push({ userID: 1, game: "iidx", playtype: "SP", isPrimary: true, - chartID: charts[i].chartID, - songID: charts[i].songID, + chartID: chart.chartID, + songID: chart.songID, calculatedData: { ktLampRating: i, }, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts index 0e49078f8..19098b63d 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/rivals/router.ts @@ -43,7 +43,11 @@ router.put( rivalIDs: [p.isPositiveNonZeroInteger], }), async (req, res) => { - const rivalIDs: Array = req.body.rivalIDs; + const body = req.safeBody as { + rivalIDs: Array; + }; + + const rivalIDs = body.rivalIDs; const { user, game, playtype } = GetUGPT(req); const result = await SetRivals(user.id, game, playtype, rivalIDs); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts index 17518346c..be30ee756 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/scores/router.test.ts @@ -1,5 +1,5 @@ import db from "external/mongo/db"; -import { ScoreDocument } from "tachi-common"; +import { ChartDocument, ScoreDocument } from "tachi-common"; import t from "tap"; import { mkFakeScoreIIDXSP, mkFakeScoreSDVX } from "test-utils/misc"; import mockApi from "test-utils/mock-api"; @@ -88,17 +88,23 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores", (t) => { t.test("Should search a user's scores.", async (t) => { const mockScores: ScoreDocument[] = []; - const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json"); + const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array>; for (let i = 0; i < 200; i++) { + const chart = charts[i]; + + if (!chart) { + return t.fail(`tachi-charts-iidx.json doesn't have enough mock data for testing. Needed atleast 200 entries, but failed to retrieve one at index ${i}.`); + } + mockScores.push({ scoreID: i.toString(), userID: 1, game: "iidx", playtype: "SP", isPrimary: true, - chartID: charts[i].chartID, - songID: charts[i].songID, + chartID: chart.chartID, + songID: chart.songID, calculatedData: { ktLampRating: i, }, diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts index ac6c508a7..06b18214e 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/settings/router.ts @@ -8,6 +8,7 @@ import { GetGamePTConfig } from "tachi-common"; import { FormatPrError, optNull } from "utils/prudence"; import { GetUGPT } from "utils/req-tachi-data"; import { FormatUserDoc } from "utils/user"; +import type { UGPTSettings } from "tachi-common"; const logger = CreateLogCtx(__filename); @@ -47,7 +48,7 @@ router.patch( }; } - const err = p(req.body, { + const err = p(req.safeBody, { preferredScoreAlg: p.optional(p.nullable(p.isIn(gptConfig.scoreRatingAlgs))), preferredSessionAlg: p.optional(p.nullable(p.isIn(gptConfig.sessionRatingAlgs))), preferredProfileAlg: p.optional(p.nullable(p.isIn(gptConfig.profileRatingAlgs))), @@ -65,43 +66,49 @@ router.patch( }); } - if (typeof req.body.defaultTable === "string") { + const body = req.safeBody as Partial; + + if (typeof body.defaultTable === "string") { const table = await db.tables.findOne({ game, playtype, - tableID: req.body.defaultTable, + tableID: body.defaultTable, }); if (!table) { return res.status(400).json({ success: false, - description: `The table (${req.body.defaultTable}) does not exist (and therefore cannot be set as a default).`, + description: `The table (${body.defaultTable}) does not exist (and therefore cannot be set as a default).`, }); } } - const updateQuery: Record = {}; + const updateQuery: Record = {}; // @warning Slightly icky dynamic prop assignment instead of copypasta. - for (const key of ["Score", "Session", "Profile"]) { - const k = `preferred${key}Alg`; + for (const key of ["Score", "Session", "Profile"] as const) { + const k = `preferred${key}Alg` as const; - if (req.body[k] !== undefined) { - updateQuery[`preferences.${k}`] = req.body[k]; + const value = body[k]; + + if (value !== undefined) { + updateQuery[`preferences.${k}`] = value; } } - if (req.body.scoreBucket !== undefined) { - updateQuery[`preferences.scoreBucket`] = req.body.scoreBucket; + if (body.scoreBucket !== undefined) { + updateQuery[`preferences.scoreBucket`] = body.scoreBucket; } - if (req.body.defaultTable !== undefined) { - updateQuery[`preferences.defaultTable`] = req.body.defaultTable; + if (body.defaultTable !== undefined) { + updateQuery[`preferences.defaultTable`] = body.defaultTable; } - if (req.body.gameSpecific) { - for (const key in req.body.gameSpecific) { - updateQuery[`preferences.gameSpecific.${key}`] = req.body.gameSpecific[key]; + if (body.gameSpecific) { + for (const [key, value] of Object.entries(body.gameSpecific)) { + // @ts-expect-error This is a very hacky way of applying changes. + // However, we know this to be correct, so we're just going to ignore it. + updateQuery[`preferences.gameSpecific.${key}`] = value; } } diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts index 42d7526b4..3a848262e 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/showcase/router.ts @@ -167,21 +167,21 @@ router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), as const gptConfig = GetGamePTConfig(game, playtype); - if (!Array.isArray(req.body)) { + if (!Array.isArray(req.safeBody)) { return res.status(400).json({ success: false, description: `No stats provided, or was not an array.`, }); } - if (req.body.length > 6) { + if (req.safeBody.length > 6) { return res.status(400).json({ success: false, description: `You are only allowed 6 stats at once.`, }); } - for (const stat of req.body) { + for (const stat of req.safeBody) { let err; if (stat?.mode === "chart") { @@ -275,7 +275,7 @@ router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), as }, { $set: { - "preferences.stats": req.body, + "preferences.stats": req.safeBody, }, } ); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts index 18a576d81..31a397b01 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.test.ts @@ -666,6 +666,10 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID", await db["goal-subs"].insert(IIDXSPMilestoneGoalSubs); await db.milestones.insert(TestingIIDXSPMilestone); + if (!IIDXSPMilestoneGoalSubs[0] || !IIDXSPMilestoneGoals[0]) { + throw new Error(`Expected atleast one milestone goal or sub to work with?`); + } + const res = await mockApi.get( `/api/v1/users/1/games/iidx/SP/targets/goals/${IIDXSPMilestoneGoalSubs[0].goalID}` ); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts index bc139973b..1885b56dc 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/targets/goals/router.ts @@ -149,7 +149,7 @@ router.post( }); } - const data = req.body as GoalCreationBody; + const data = req.safeBody as GoalCreationBody; let goal; diff --git a/server/src/server/router/api/v1/users/_userID/integrations/arc/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/arc/router.ts index aab7bdaba..c84b91ebf 100644 --- a/server/src/server/router/api/v1/users/_userID/integrations/arc/router.ts +++ b/server/src/server/router/api/v1/users/_userID/integrations/arc/router.ts @@ -2,10 +2,10 @@ import { RequireSelfRequestFromUser } from "../../middleware"; import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TACHI_DATA } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import prValidate from "server/middleware/prudence-validate"; import { RequireKamaitachi } from "server/middleware/type-require"; +import { IsNonEmptyString } from "utils/misc"; import { GetArcAuth } from "utils/queries/auth"; import { GetTachiData } from "utils/req-tachi-data"; import { FormatUserDoc } from "utils/user"; @@ -46,7 +46,12 @@ router.get("/", async (req, res) => { router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req, res) => { const user = GetTachiData(req, "requestedUser"); - if (Object.keys(req.body).length === 0) { + const body = req.safeBody as { + iidx?: string | null; + sdvx?: string | null; + }; + + if (Object.keys(body).length === 0) { return res.status(400).json({ success: false, description: `Invalid request to modify nothing.`, @@ -63,7 +68,9 @@ router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req for (const key of ["iidx", "sdvx"] as const) { const importType = `api/arc-${key}` as const; - if (req.body[key] === null) { + const value = body[key]; + + if (value === null) { logger.info(`User ${FormatUserDoc(user)} removed ARC integration for ${importType}.`); await db["arc-saved-profiles"].remove( @@ -75,7 +82,7 @@ router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req single: true, } ); - } else if (req.body[key]) { + } else if (IsNonEmptyString(value)) { if (existingData[key]) { logger.info(`User updated ARC integration for ${importType}.`); await db["arc-saved-profiles"].update( @@ -85,7 +92,7 @@ router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req }, { $set: { - accountID: req.body[key], + accountID: value, }, } ); @@ -94,7 +101,7 @@ router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req await db["arc-saved-profiles"].insert({ userID: user.id, forImportType: importType, - accountID: req.body[key], + accountID: value, }); } } diff --git a/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts index d4398a805..46e6114bf 100644 --- a/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts +++ b/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts @@ -1,7 +1,6 @@ import { RequireSelfRequestFromUser } from "../../middleware"; import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TACHI_DATA } from "lib/constants/tachi"; import prValidate from "server/middleware/prudence-validate"; import { RequireKamaitachi } from "server/middleware/type-require"; import { DeleteUndefinedProps } from "utils/misc"; @@ -44,7 +43,12 @@ router.patch( "/settings", prValidate({ cards: optNull(["string"]), forceStaticImport: "*?boolean" }), async (req, res) => { - if (req.body.cards && req.body.cards.length > 6) { + const body = req.safeBody as { + cards?: Array | null; + forceStaticImport?: boolean | null; + }; + + if (body.cards && body.cards.length > 6) { return res.status(400).json({ success: false, description: `You cannot have more than 6 card filters at once.`, @@ -53,7 +57,7 @@ router.patch( const user = GetTachiData(req, "requestedUser"); - const modifyDocument = req.body; + const modifyDocument = req.safeBody; DeleteUndefinedProps(modifyDocument); diff --git a/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts index 8f2f3b364..e23ae24fd 100644 --- a/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts +++ b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts @@ -73,7 +73,7 @@ router.post( const user = GetTachiData(req, "requestedUser"); const kaiType = NotNullish(req.params.kaiType).toUpperCase() as "EAG" | "FLO" | "MIN"; - const body = req.body as { + const body = req.safeBody as { code: string; }; diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index bad452f5a..53042bab3 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -89,7 +89,7 @@ router.patch( async (req, res) => { const user = NotNullish(req[SYMBOL_TACHI_DATA]?.requestedUser); - const body = req.body as UserPatchBody; + const body = req.safeBody as UserPatchBody; if (Object.keys(body).length === 0) { return res.status(400).json({ @@ -311,7 +311,7 @@ router.post( "!oldPassword": ValidatePassword, }), async (req, res) => { - const body = req.body as { + const body = req.safeBody as { "!password": string; "!oldPassword": string; }; diff --git a/server/src/server/router/api/v1/users/_userID/settings/router.ts b/server/src/server/router/api/v1/users/_userID/settings/router.ts index b92eec29e..1f3560204 100644 --- a/server/src/server/router/api/v1/users/_userID/settings/router.ts +++ b/server/src/server/router/api/v1/users/_userID/settings/router.ts @@ -1,10 +1,8 @@ import { RequireSelfRequestFromUser } from "../middleware"; import { Router } from "express"; import db from "external/mongo/db"; -import { SYMBOL_TACHI_DATA } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import prValidate from "server/middleware/prudence-validate"; -import { DeleteUndefinedProps } from "utils/misc"; import { GetTachiData } from "utils/req-tachi-data"; import { FormatUserDoc, GetSettingsForUser } from "utils/user"; @@ -61,16 +59,14 @@ router.patch( async (req, res) => { const user = GetTachiData(req, "requestedUser"); - const preferences = { - invisible: req.body.invisible, - developerMode: req.body.developerMode, - contentiousContent: req.body.contentiousContent, - advancedMode: req.body.advancedMode, - deletableScores: req.body.deletableScores, + const preferences = req.safeBody as { + invisible?: boolean; + developerMode?: boolean; + contentiousContent?: boolean; + advancedMode?: boolean; + deletableScores?: boolean; }; - DeleteUndefinedProps(preferences); - if (Object.keys(preferences).length === 0) { return res.status(400).json({ success: false, diff --git a/server/src/server/router/ir/barbatos/router.ts b/server/src/server/router/ir/barbatos/router.ts index 142b5381a..c6c82d65f 100644 --- a/server/src/server/router/ir/barbatos/router.ts +++ b/server/src/server/router/ir/barbatos/router.ts @@ -13,10 +13,10 @@ router.use(RequirePermissions("submit_score")); */ router.post("/score/submit", async (req, res) => { const responseData = await ExpressWrappedScoreImportMain( - req[SYMBOL_TACHI_API_AUTH]!.userID!, + req[SYMBOL_TACHI_API_AUTH].userID!, false, "ir/barbatos", - [req.body] + [req.safeBody] ); return res.status(responseData.statusCode).json(responseData.body); diff --git a/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts b/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts index 94f6c1652..47f902e5b 100644 --- a/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts +++ b/server/src/server/router/ir/beatoraja/charts/_chartSHA256/convert-scores.test.ts @@ -2,11 +2,9 @@ import deepmerge from "deepmerge"; import { PBScoreDocument } from "tachi-common"; import t from "tap"; import ResetDBState from "test-utils/resets"; -import { GetKTDataJSON } from "test-utils/test-data"; +import { BMSGazerChart, GetKTDataJSON } from "test-utils/test-data"; import { TachiScoreDataToBeatorajaFormat } from "./convert-scores"; -const gazerChart = GetKTDataJSON("./tachi/bms-gazer-chart.json"); - const pbScore = { composedFrom: { lampPB: "mock_lampPB", @@ -18,7 +16,7 @@ const pbScore = { }, playtype: "7K", scoreMeta: {}, - chartID: gazerChart.chartID, + chartID: BMSGazerChart.chartID, userID: 1, } as unknown as PBScoreDocument<"bms:7K" | "bms:14K">; @@ -28,9 +26,9 @@ t.test("#TachiScoreDataToBeatorajaFormat", (t) => { t.test("Should convert score.", (t) => { const res = TachiScoreDataToBeatorajaFormat( pbScore, - gazerChart.data.hashSHA256, + BMSGazerChart.data.hashSHA256, "", - gazerChart.data.notecount, + BMSGazerChart.data.notecount, 0 ); @@ -71,9 +69,9 @@ t.test("#TachiScoreDataToBeatorajaFormat", (t) => { t.test("Should fake epg/egr data if the score doesn't have it..", (t) => { const res = TachiScoreDataToBeatorajaFormat( deepmerge(pbScore, { scoreData: { score: 999 } }), - gazerChart.data.hashSHA256, + BMSGazerChart.data.hashSHA256, "", - gazerChart.data.notecount, + BMSGazerChart.data.notecount, 0 ); @@ -114,9 +112,9 @@ t.test("#TachiScoreDataToBeatorajaFormat", (t) => { t.test("Should emplace username if requestingUserID is not the pbscore owner", (t) => { const res = TachiScoreDataToBeatorajaFormat( pbScore, - gazerChart.data.hashSHA256, + BMSGazerChart.data.hashSHA256, "test_zkldi", - gazerChart.data.notecount, + BMSGazerChart.data.notecount, 0 ); diff --git a/server/src/server/router/ir/beatoraja/router.test.ts b/server/src/server/router/ir/beatoraja/router.test.ts index 58d3dfe0b..246c0872f 100644 --- a/server/src/server/router/ir/beatoraja/router.test.ts +++ b/server/src/server/router/ir/beatoraja/router.test.ts @@ -4,7 +4,7 @@ import { PublicUserDocument } from "tachi-common"; import t from "tap"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; -import { GetKTDataJSON } from "test-utils/test-data"; +import { GetKTDataJSON, MockBeatorajaBMSScore, MockBeatorajaPMSScore } from "test-utils/test-data"; t.test("POST /ir/beatoraja/submit-score", (t) => { t.beforeEach(ResetDBState); @@ -20,15 +20,12 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { }) ); - const bmsScoreReq = GetKTDataJSON("./beatoraja/base.json"); - const pmsScoreReq = GetKTDataJSON("./beatoraja/pms-base.json"); - t.test("Should import a valid BMS score.", async (t) => { const res = await mockApi .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") - .send(bmsScoreReq); + .send(MockBeatorajaBMSScore); t.equal(res.status, 200); @@ -67,7 +64,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") - .send(pmsScoreReq); + .send(MockBeatorajaPMSScore); t.equal(res.status, 200); @@ -106,7 +103,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") - .send(deepmerge(pmsScoreReq, { score: { deviceType: "KEYBOARD" } })); + .send(deepmerge(MockBeatorajaPMSScore, { score: { deviceType: "KEYBOARD" } })); t.equal(res.status, 200); @@ -145,7 +142,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") - .send(deepmerge(bmsScoreReq, { client: "INVALID" })); + .send(deepmerge(MockBeatorajaBMSScore, { client: "INVALID" })); t.equal(res.status, 400); @@ -160,7 +157,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") - .send(deepmerge(bmsScoreReq, { client: "beatoraja 0.8.0" })); + .send(deepmerge(MockBeatorajaBMSScore, { client: "beatoraja 0.8.0" })); t.equal(res.status, 400); @@ -175,7 +172,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") - .send(deepmerge(pmsScoreReq, { client: "LR2oraja 0.8.0" })); + .send(deepmerge(MockBeatorajaPMSScore, { client: "LR2oraja 0.8.0" })); t.equal(res.status, 400); @@ -190,7 +187,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") - .send(deepmerge(bmsScoreReq, { score: { exscore: -1 } })); + .send(deepmerge(MockBeatorajaBMSScore, { score: { exscore: -1 } })); t.equal(res.status, 400); @@ -205,7 +202,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") - .send(deepmerge(bmsScoreReq, { chart: { title: null } })); + .send(deepmerge(MockBeatorajaBMSScore, { chart: { title: null } })); t.equal(res.status, 400); @@ -221,7 +218,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") .send( - deepmerge(bmsScoreReq, { + deepmerge(MockBeatorajaBMSScore, { chart: { sha256: "new_chart", md5: "new_md5" }, score: { sha256: "new_chart", md5: "new_md5" }, }) @@ -282,7 +279,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer mock_token") .send( - deepmerge(bmsScoreReq, { + deepmerge(MockBeatorajaBMSScore, { chart: { sha256: "new_chart", md5: "new_md5" }, score: { sha256: "new_chart", md5: "new_md5" }, }) @@ -295,7 +292,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer token2") .send( - deepmerge(bmsScoreReq, { + deepmerge(MockBeatorajaBMSScore, { chart: { sha256: "new_chart", md5: "new_md5" }, score: { sha256: "new_chart", md5: "new_md5" }, }) @@ -314,7 +311,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer token3") .send( - deepmerge(bmsScoreReq, { + deepmerge(MockBeatorajaBMSScore, { chart: { sha256: "new_chart", md5: "new_md5" }, score: { sha256: "new_chart", md5: "new_md5" }, }) @@ -347,7 +344,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { const res = await mockApi .post("/ir/beatoraja/submit-score") .set("X-TachiIR-Version", "v2.0.0") - .send(bmsScoreReq); + .send(MockBeatorajaBMSScore); t.equal(res.status, 401); @@ -360,7 +357,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => { .set("X-TachiIR-Version", "v2.0.0") .set("Authorization", "Bearer invalid_token") - .send(bmsScoreReq); + .send(MockBeatorajaBMSScore); t.equal(res.status, 401); diff --git a/server/src/server/router/ir/beatoraja/router.ts b/server/src/server/router/ir/beatoraja/router.ts index 1ca88c77b..94eed2f25 100644 --- a/server/src/server/router/ir/beatoraja/router.ts +++ b/server/src/server/router/ir/beatoraja/router.ts @@ -29,7 +29,7 @@ router.post("/submit-score", RequireNotGuest, async (req, res) => { const userID = NotNullish(req[SYMBOL_TACHI_API_AUTH].userID); const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/beatoraja", [ - req.body, + req.safeBody, userID, ]); @@ -43,7 +43,7 @@ router.post("/submit-score", RequireNotGuest, async (req, res) => { // the chart and score values were atleast typed correctly // and can afford to make this assertion. if (type === "KTDataNotFound") { - const { chart } = req.body as { chart: BeatorajaChart }; + const { chart } = req.safeBody as { chart: BeatorajaChart }; const orphanInfo: { userIDs: Array } | null = await db[ "orphan-chart-queue" @@ -56,7 +56,7 @@ router.post("/submit-score", RequireNotGuest, async (req, res) => { if (!orphanInfo) { logger.warn(`Chart '${chart.sha256}' got KTDataNotFound, but was not orphaned?`, { - body: req.body as unknown, + body: req.safeBody as unknown, }); return res.status(400).json({ @@ -192,7 +192,7 @@ router.post( }), RequireNotGuest, async (req, res) => { - const body = req.body as { + const body = req.safeBody as { course: { charts: Array<{ md5: string }>; constraint: Array<"GAUGE_LR2" | "LN" | "MIRROR">; 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 2779855af..8a4d1b61f 100644 --- a/server/src/server/router/ir/direct-manual/router.test.ts +++ b/server/src/server/router/ir/direct-manual/router.test.ts @@ -5,7 +5,7 @@ import t from "tap"; import { CreateFakeAuthCookie } from "test-utils/fake-auth"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; -import { GetKTDataJSON } from "test-utils/test-data"; +import { FakeChunitachiBatchManual, FakeSmallBatchManual, GetKTDataJSON } from "test-utils/test-data"; t.test("POST /ir/direct-manual/import", async (t) => { const cookie = await CreateFakeAuthCookie(mockApi); @@ -38,7 +38,7 @@ t.test("POST /ir/direct-manual/import", async (t) => { const res = await mockApi .post("/ir/direct-manual/import") .set("Cookie", cookie) - .send(GetKTDataJSON("./batch-manual/small-file.json")); + .send(FakeSmallBatchManual); t.equal(res.body.success, true, "Should be successful"); @@ -62,7 +62,7 @@ t.test("POST /ir/direct-manual/import", async (t) => { t.test("Should require authentication.", async (t) => { const res = await mockApi .post("/ir/direct-manual/import") - .send(GetKTDataJSON("./batch-manual/small-file.json")); + .send(FakeSmallBatchManual); t.equal(res.statusCode, 401); @@ -73,7 +73,7 @@ t.test("POST /ir/direct-manual/import", async (t) => { const res = await mockApi .post("/ir/direct-manual/import") .set("Authorization", "Bearer invalid_token") - .send(GetKTDataJSON("./batch-manual/small-file.json")); + .send(FakeSmallBatchManual); t.equal(res.statusCode, 401); @@ -90,13 +90,11 @@ t.test("POST /ir/direct-manual/import", async (t) => { }) ); - const chunitachiBody = GetKTDataJSON("./batch-manual/chunitachi.json"); - t.test("Should work for CHUNITACHI requests", async (t) => { const res = await mockApi .post("/ir/direct-manual/import") .set("Authorization", `Bearer mock_token`) - .send(chunitachiBody); + .send(FakeChunitachiBatchManual); t.equal(res.body.success, true, "Should be successful"); @@ -121,7 +119,7 @@ t.test("POST /ir/direct-manual/import", async (t) => { }); t.test("Should require authentication.", async (t) => { - const res = await mockApi.post("/ir/direct-manual/import").send(chunitachiBody); + const res = await mockApi.post("/ir/direct-manual/import").send(FakeChunitachiBatchManual); t.equal(res.statusCode, 401); @@ -132,7 +130,7 @@ t.test("POST /ir/direct-manual/import", async (t) => { const res = await mockApi .post("/ir/direct-manual/import") .set("Authorization", "Bearer invalid_token") - .send(chunitachiBody); + .send(FakeChunitachiBatchManual); t.equal(res.statusCode, 401); diff --git a/server/src/server/router/ir/direct-manual/router.ts b/server/src/server/router/ir/direct-manual/router.ts index af83c2637..a3e59ecce 100644 --- a/server/src/server/router/ir/direct-manual/router.ts +++ b/server/src/server/router/ir/direct-manual/router.ts @@ -29,7 +29,7 @@ router.post( userID: req[SYMBOL_TACHI_API_AUTH].userID!, userIntent, importType: "ir/direct-manual", - parserArguments: [req.body], + parserArguments: [req.safeBody], }; // Fire the score import, but make no guarantees about its state. @@ -51,7 +51,7 @@ router.post( req[SYMBOL_TACHI_API_AUTH].userID!, userIntent, "ir/direct-manual", - [req.body] + [req.safeBody] ); return res.status(importResponse.statusCode).json(importResponse.body); diff --git a/server/src/server/router/ir/fervidex/router.ts b/server/src/server/router/ir/fervidex/router.ts index 45ab6937a..52f744502 100644 --- a/server/src/server/router/ir/fervidex/router.ts +++ b/server/src/server/router/ir/fervidex/router.ts @@ -184,7 +184,7 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => { }; const ValidateCards: RequestHandler = async (req, res, next) => { - const userID = req[SYMBOL_TACHI_API_AUTH]!.userID!; + const userID = req[SYMBOL_TACHI_API_AUTH].userID!; const cardFilters = await db["fer-settings"].findOne({ userID }); @@ -244,7 +244,7 @@ router.post("/profile/submit", RequireInf2ModelHeaderOrForceStatic, (req, res) = req[SYMBOL_TACHI_API_AUTH].userID!, false, "ir/fervidex-static", - [req.body, headers] + [req.safeBody, headers] ); }); @@ -273,7 +273,7 @@ router.post("/score/submit", ValidateModelHeader, async (req, res) => { req[SYMBOL_TACHI_API_AUTH].userID!, true, "ir/fervidex", - [req.body, headers] + [req.safeBody, headers] ); if (!responseData.body.success) { @@ -307,7 +307,7 @@ router.post( ), ValidateModelHeader, async (req, res) => { - const body = req.body as { + const body = req.safeBody as { cleared: boolean; course_id: integer; play_style: 0 | 1; diff --git a/server/src/server/router/ir/kshook/sv6c/router.ts b/server/src/server/router/ir/kshook/sv6c/router.ts index fa361cb86..4cdb40dc5 100644 --- a/server/src/server/router/ir/kshook/sv6c/router.ts +++ b/server/src/server/router/ir/kshook/sv6c/router.ts @@ -4,6 +4,7 @@ import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper"; import { ParseEA3SoftID } from "utils/ea3id"; +import { IsNullishOrEmptyStr } from "utils/misc"; import type { RequestHandler } from "express"; const router: Router = Router({ mergeParams: true }); @@ -13,9 +14,9 @@ const logger = CreateLogCtx(__filename); const ValidateHeaders: RequestHandler = (req, res, next) => { const agent = req.header("User-Agent"); - if (!agent) { + if (IsNullishOrEmptyStr(agent)) { logger.debug( - `Rejected KsHook client with no agent from user ${req[SYMBOL_TACHI_API_AUTH].userID!}.` + `Rejected KsHook client with no agent from user ${req[SYMBOL_TACHI_API_AUTH].userID}.` ); return res.status(400).json({ success: false, @@ -25,9 +26,7 @@ const ValidateHeaders: RequestHandler = (req, res, next) => { if (!agent.startsWith("kshook/")) { logger.info( - `Rejected KsHook client with invalid agent ${agent} from user ${req[ - SYMBOL_TACHI_API_AUTH - ].userID!}.` + `Rejected KsHook client with invalid agent ${agent} from user ${req[SYMBOL_TACHI_API_AUTH].userID}.` ); return res.status(400).json({ success: false, @@ -40,9 +39,9 @@ const ValidateHeaders: RequestHandler = (req, res, next) => { const softID = req.header("X-Software-Model"); - if (!softID) { + if (IsNullishOrEmptyStr(softID)) { logger.debug( - `received request without X-Software-Model from ${req[SYMBOL_TACHI_API_AUTH].userID!}.` + `received request without X-Software-Model from ${req[SYMBOL_TACHI_API_AUTH].userID}.` ); return res.status(400).json({ success: false, @@ -63,7 +62,7 @@ const ValidateHeaders: RequestHandler = (req, res, next) => { }); } } catch (err) { - logger.info(`Invalid softID from ${req[SYMBOL_TACHI_API_AUTH].userID!}.`, { err }); + logger.info(`Invalid softID from ${req[SYMBOL_TACHI_API_AUTH].userID}.`, { err }); return res.status(400).json({ success: false, error: `Invalid X-Software-Model.`, @@ -85,7 +84,7 @@ router.post("/score/save", async (req, res) => { req[SYMBOL_TACHI_API_AUTH].userID!, true, "ir/kshook-sv6c", - [req.body] + [req.safeBody] ); if (!responseData.body.success) { diff --git a/server/src/server/router/ir/lr2hook/router.ts b/server/src/server/router/ir/lr2hook/router.ts index 42425228d..0ff74e270 100644 --- a/server/src/server/router/ir/lr2hook/router.ts +++ b/server/src/server/router/ir/lr2hook/router.ts @@ -2,7 +2,7 @@ import { Router } from "express"; import db from "external/mongo/db"; import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi"; import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper"; -import { PR_LR2Hook } from "lib/score-import/import-types/ir/lr2hook/parser"; +import { PR_LR2Hook as PR_LR2_HOOK } from "lib/score-import/import-types/ir/lr2hook/parser"; import { RequirePermissions } from "server/middleware/auth"; import prValidate from "server/middleware/prudence-validate"; import { UpdateClassIfGreater } from "utils/class"; @@ -22,7 +22,7 @@ router.post("/import", async (req, res) => { req[SYMBOL_TACHI_API_AUTH].userID!, false, "ir/lr2hook", - [req.body] + [req.safeBody] ); return res.status(importRes.statusCode).json(importRes.body); @@ -33,11 +33,13 @@ router.post("/import", async (req, res) => { * * @name POST /ir/lr2hook/import/course */ -router.post("/import/course", prValidate(PR_LR2Hook), async (req, res) => { +router.post("/import/course", prValidate(PR_LR2_HOOK), async (req, res) => { // notably, courses in LR2 are actually identical to scores. They have all // the same fields in all the same ways. The only significant difference is that // the md5 field is 4 fields conjoined, rather than just one. - const score: LR2HookScore = req.body; + + // This type assertion is safe due to the prValidate call above. + const score = req.safeBody as unknown as LR2HookScore; if (score.scoreData.notesPlayed !== score.scoreData.notesTotal) { return res.status(200).json({ @@ -61,7 +63,7 @@ router.post("/import/course", prValidate(PR_LR2Hook), async (req, res) => { }); } - const userID = req[SYMBOL_TACHI_API_AUTH]!.userID!; + const userID = req[SYMBOL_TACHI_API_AUTH].userID!; const result = await UpdateClassIfGreater( userID, diff --git a/server/src/server/router/ir/usc/_playtype/router.ts b/server/src/server/router/ir/usc/_playtype/router.ts index d8885d126..1b468da50 100644 --- a/server/src/server/router/ir/usc/_playtype/router.ts +++ b/server/src/server/router/ir/usc/_playtype/router.ts @@ -4,7 +4,7 @@ import db from "external/mongo/db"; import { CDNStoreOrOverwrite } from "lib/cdn/cdn"; import { GetUSCIRReplayURL } from "lib/cdn/url-format"; import { ONE_MEGABYTE } from "lib/constants/filesize"; -import { SYMBOL_TACHI_API_AUTH, SYMBOL_TACHI_DATA } from "lib/constants/tachi"; +import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi"; import { USCIR_MAX_LEADERBOARD_N } from "lib/constants/usc-ir"; import CreateLogCtx from "lib/logger/logger"; import { AssertStrAsPositiveNonZeroInt } from "lib/score-import/framework/common/string-asserts"; @@ -116,10 +116,19 @@ const RetrieveChart: RequestHandler = async (req, res, next) => { playtype: req.params.playtype as Playtypes["usc"], }); + if (!chart) { + return res.status(200).json({ + statusCode: STATUS_CODES.NOT_FOUND, + description: `This IR doesn't have any record data yet, or ${ + ServerConfig.USC_QUEUE_SIZE + } ${ + ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have" + } not played the chart yet.`, + }); + } + AssignToReqTachiData(req, { - uscChartDoc: (chart ?? undefined) as - | ChartDocument<"usc:Controller" | "usc:Keyboard"> - | undefined, + uscChartDoc: chart as ChartDocument<"usc:Controller" | "usc:Keyboard">, }); next(); @@ -131,15 +140,6 @@ const RetrieveChart: RequestHandler = async (req, res, next) => { * @name GET /ir/usc/:playtype/charts/:chartHash */ router.get("/charts/:chartHash", RetrieveChart, (req, res) => { - const chart = GetTachiData(req, "uscChartDoc"); - - if (!chart) { - return res.status(200).json({ - statusCode: STATUS_CODES.NOT_FOUND, - description: "This chart is not available on the IR yet, more people need to play it!", - }); - } - return res.status(200).json({ statusCode: STATUS_CODES.SUCCESS, description: "This chart is tracked by the IR.", @@ -155,17 +155,6 @@ router.get("/charts/:chartHash", RetrieveChart, (req, res) => { router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => { const chart = GetTachiData(req, "uscChartDoc"); - if (!chart) { - return res.status(200).json({ - statusCode: STATUS_CODES.NOT_FOUND, - description: `This IR doesn't have any record data yet, or ${ - ServerConfig.USC_QUEUE_SIZE - } ${ - ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have" - } not played the chart yet.`, - }); - } - const serverRecord = (await db["personal-bests"].findOne({ chartID: chart.chartID, "rankingData.rank": 1, @@ -195,17 +184,6 @@ router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => { router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => { const chart = GetTachiData(req, "uscChartDoc"); - if (!chart) { - return res.status(200).json({ - statusCode: STATUS_CODES.NOT_FOUND, - description: `This IR doesn't have any record data yet, or ${ - ServerConfig.USC_QUEUE_SIZE - } ${ - ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have" - } not played the chart yet.`, - }); - } - if (!(typeof req.query.mode === "string" && ["best", "rivals"].includes(req.query.mode))) { return res.status(200).json({ statusCode: STATUS_CODES.BAD_REQ, @@ -285,7 +263,7 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { const playtype = req.params.playtype as Playtypes["usc"]; const chartErr = p( - req.body.chart, + req.safeBody.chart, PR_USCIR_CHART_DOC, {}, { @@ -301,17 +279,17 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { }); } - const uscChart = req.body.chart as USCClientChart; + const uscChart = req.safeBody.chart as USCClientChart; const chartDoc = (await db.charts.usc.findOne({ "data.hashSHA1": uscChart.chartHash, playtype, })) as ChartDocument<"usc:Controller" | "usc:Keyboard"> | null; - const userID = req[SYMBOL_TACHI_API_AUTH]!.userID!; + const userID = req[SYMBOL_TACHI_API_AUTH].userID!; const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/usc", [ - req.body, + req.safeBody, uscChart.chartHash, playtype, ]); @@ -332,20 +310,15 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => { // If the import failed, AND the import failure WAS NOT that the chart didnt exist // report that error instead. - if (importDoc.errors[0]?.type && importDoc.errors[0].type !== "KTDataNotFound") { - logger.info( - `USC Import Failed ${importDoc.errors[0].message ?? "with null error message?"}`, - { - importDoc, - userID, - } - ); + if (importDoc.errors[0] && importDoc.errors[0].type !== "KTDataNotFound") { + logger.info(`USC Import Failed ${importDoc.errors[0].message}`, { + importDoc, + userID, + }); return res.status(200).json({ statusCode: STATUS_CODES.BAD_REQ, - description: `${importDoc.errors[0].type} ${ - importDoc.errors[0].message ?? "No Error Message" - }`, + description: `${importDoc.errors[0].type} ${importDoc.errors[0].message}`, }); } @@ -405,7 +378,7 @@ router.post( RequirePermissions("submit_score"), CreateMulterSingleUploadMiddleware("replay", ONE_MEGABYTE, logger, false), async (req, res) => { - if (typeof req.body.identifier !== "string") { + if (typeof req.safeBody.identifier !== "string") { return res.status(200).json({ statusCode: STATUS_CODES.BAD_REQ, description: "No Identifier Provided.", @@ -425,8 +398,8 @@ router.post( // Otherwise, anyone could overwrite anyone elses // score replays! const correspondingScore = await db.scores.findOne({ - userID: req[SYMBOL_TACHI_API_AUTH]!.userID!, - scoreID: req.body.identifier, + userID: req[SYMBOL_TACHI_API_AUTH].userID!, + scoreID: req.safeBody.identifier, game: "usc", }); diff --git a/server/src/server/server.ts b/server/src/server/server.ts index 58527cb00..fbe6f4616 100644 --- a/server/src/server/server.ts +++ b/server/src/server/server.ts @@ -1,17 +1,21 @@ +// THIS IMPORT **MUST** GO HERE. DO NOT MOVE IT. IT MUST OCCUR BEFORE ANYTHING HAPPENS WITH EXPRESS +// BUT AFTER EXPRESS IS IMPORTED. +// eslint-disable-next-line import/order +import express from "express"; +import "express-async-errors"; + import { RequestLoggerMiddleware } from "./middleware/request-logger"; import mainRouter from "./router/router"; import connectRedis from "connect-redis"; -import express from "express"; import expressSession from "express-session"; import { RedisClient } from "external/redis/redis"; import helmet from "helmet"; import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import { Environment, ServerConfig, TachiConfig } from "lib/setup/config"; +import { IsNonEmptyString, IsRecord } from "utils/misc"; import type { Express } from "express"; -import "express-async-errors"; import type { integer } from "tachi-common"; -import { IsNonEmptyString } from "utils/misc"; const logger = CreateLogCtx(__filename); @@ -53,9 +57,17 @@ if (Environment.nodeEnv !== "production" && IsNonEmptyString(ServerConfig.CLIENT bootInfo: true, }); + // Note: we have to assign it here to make sure it doesn't get modified! + // If we try and use ServerConfig.CLIENT_DEV_SERVER inside the callback, TS rightly + // complains that this value might end up being mutated to null/undefined. + // + // Even though we don't do that, + // we may aswell be correct about the whole thing. + const clientDevServerLocation = ServerConfig.CLIENT_DEV_SERVER; + // Allow CORS requests from another server (since we have our dev server hosted separately). app.use((req, res, next) => { - res.header("Access-Control-Allow-Origin", ServerConfig.CLIENT_DEV_SERVER!); + res.header("Access-Control-Allow-Origin", clientDevServerLocation); res.header( "Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, X-User-Intent" @@ -103,11 +115,14 @@ app.use(express.json({ limit: "4mb" })); app.use((req, res, next) => { // Always mount an empty req body. We operate under the assumption that req.body is - // always defined. - if (req.method !== "GET" && !req.body) { + // always defined as atleast an object. + if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) { req.body = {}; } + // req.safeBody *is* just a type-safe req.body! + req.safeBody = req.body as Record; + next(); }); @@ -143,8 +158,9 @@ interface ExpressJSONErr extends SyntaxError { message: string; } -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, next) => { +const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, _next) => { + logger.info(`MAIN_ERR_HANDLER hit by request.`, { url: req.originalUrl }); + if (err instanceof SyntaxError) { const expErr: ExpressJSONErr = err as ExpressJSONErr; @@ -159,14 +175,21 @@ const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, next) => { // else, this isn't a JSON parsing error } - if (err.type === "entity.too.large") { + const unknownErr = err as unknown; + + if (IsRecord(unknownErr) && unknownErr.type === "entity.too.large") { return res.status(413).json({ success: false, description: "Your request body was too large. The limit is 4MB.", }); } - logger.error("Fatal error propagated to server root? ", { err, route: req.route }); + logger.error("Fatal error propagated to server root? ", { + err: unknownErr, + url: req.originalUrl, + authInfo: req[SYMBOL_TACHI_API_AUTH], + }); + return res.status(500).json({ success: false, description: "A fatal internal server error has occured.", diff --git a/server/src/test-utils/fake-auth.ts b/server/src/test-utils/fake-auth.ts index 95c33e89a..8400c9114 100644 --- a/server/src/test-utils/fake-auth.ts +++ b/server/src/test-utils/fake-auth.ts @@ -23,7 +23,15 @@ export async function CreateFakeAuthCookie(mockApi: supertest.SuperTest; + const headers = res.headers as Record>; + + const setCookieHeader: Array | undefined = headers["set-cookie"]; + + if (setCookieHeader === undefined) { + throw new Error(`Failed to login, no Set-Cookie returned in response?`); + } + + return setCookieHeader; } // my local dev env hates this part because of pnpm diff --git a/server/src/test-utils/misc.ts b/server/src/test-utils/misc.ts index c912d6a33..fef18f6ca 100644 --- a/server/src/test-utils/misc.ts +++ b/server/src/test-utils/misc.ts @@ -3,6 +3,8 @@ import { FakeImport, FakeNotification, FakeOtherUser, + HC511Goal, + HC511UserGoal, TestingIIDXSPScore, TestingIIDXSPScorePB, TestingSDVXScore, @@ -10,6 +12,8 @@ import { import deepmerge from "deepmerge"; import type { Game, + GoalDocument, + GoalSubscriptionDocument, ImportDocument, integer, NotificationDocument, @@ -39,7 +43,7 @@ export async function agta(ag: AsyncIterable | Iterable) { export function dmf(base: T, modifant: Partial): T { return deepmerge(base, modifant, { // The new array should replace the former one, instead of joining them together. - arrayMerge: (originalArray, newArray) => newArray, + arrayMerge: (originalArray, newArray) => newArray as Array, }); } @@ -91,3 +95,11 @@ export function mkFakePBIIDXSP(modifant: Partial> = { export function mkFakeNotification(modifant: Partial = {}) { return dmf(FakeNotification, modifant); } + +export function mkFakeGoal(modifant: Partial = {}) { + return dmf(HC511Goal, modifant); +} + +export function mkFakeGoalSub(modifant: Partial = {}) { + return dmf(HC511UserGoal, modifant); +} diff --git a/server/src/test-utils/mock-db/charts-bms.json b/server/src/test-utils/mock-db/charts-bms.json index 927c962a7..46914890b 100644 --- a/server/src/test-utils/mock-db/charts-bms.json +++ b/server/src/test-utils/mock-db/charts-bms.json @@ -7,12 +7,17 @@ "notecount": 2256, "hashMD5": "38616b85332037cc12924f2ae2840262", "hashSHA256": "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", - "tableFolders": [] + "tableFolders": [ + { + "level": "17", + "table": "★" + } + ] }, "tierlistInfo": {}, "level": "?", "levelNum": 0, - "difficulty": "CUSTOM", + "difficulty": "CHART", "playtype": "7K", "isPrimary": true, "versions": [] diff --git a/server/src/test-utils/mock-db/songs-bms.json b/server/src/test-utils/mock-db/songs-bms.json index 61ebc8745..a396b8512 100644 --- a/server/src/test-utils/mock-db/songs-bms.json +++ b/server/src/test-utils/mock-db/songs-bms.json @@ -6,7 +6,8 @@ "data": { "subtitle": null, "subartist": null, - "genre": "ELECTRANCE" + "genre": "ELECTRANCE", + "tableString": null }, "searchTerms": [], "altTitles": [] diff --git a/server/src/test-utils/mock-fetch.ts b/server/src/test-utils/mock-fetch.ts index 6d6d24ceb..6c5e883b8 100644 --- a/server/src/test-utils/mock-fetch.ts +++ b/server/src/test-utils/mock-fetch.ts @@ -7,7 +7,7 @@ import type { NodeFetch } from "utils/fetch"; * Creates a basic Fetch function used for statusCode checks. */ export function MockBasicFetch(data: Partial) { - return (async () => data) as unknown as NodeFetch; + return (() => data) as unknown as NodeFetch; } /** @@ -15,18 +15,24 @@ export function MockBasicFetch(data: Partial) { * a fake JSON -> data function at that key. */ export function MockJSONFetch(urlDataMap: Record) { - return (async (url: string) => { - if (urlDataMap[url]) { - return { - status: 200, - json: async () => urlDataMap[url], - }; - } + return ((url: string) => + new Promise((resolve, reject) => { + if (url in urlDataMap) { + const fakeResponse = { + status: 200, + json: () => Promise.resolve(urlDataMap[url]), + } as unknown as Response; - throw new Error( - `Unexpected url ${url} - No Data Present? Valid urls are ${Object.keys(urlDataMap).join( - ", " - )}` - ); - }) as NodeFetch; + resolve(fakeResponse); + return; + } + + reject( + new Error( + `Unexpected url ${url} - No Data Present? Valid urls are ${Object.keys( + urlDataMap + ).join(", ")}` + ) + ); + })) as NodeFetch; } diff --git a/server/src/test-utils/mock-multer.ts b/server/src/test-utils/mock-multer.ts index ac0694dd5..a476f6575 100644 --- a/server/src/test-utils/mock-multer.ts +++ b/server/src/test-utils/mock-multer.ts @@ -1,8 +1,8 @@ export function MockMulterFile(buffer: Buffer, originalname: string) { - const mockFile: Express.Multer.File = { + const mockFile = { originalname, buffer, - }; + } as unknown as Express.Multer.File; return mockFile; } diff --git a/server/src/test-utils/resets.ts b/server/src/test-utils/resets.ts index cf5f89004..b7bb67967 100644 --- a/server/src/test-utils/resets.ts +++ b/server/src/test-utils/resets.ts @@ -8,6 +8,9 @@ import { Environment, ServerConfig } from "lib/setup/config"; import rimraf from "rimraf"; import fs from "fs"; import path from "path"; +import type { StaticDatabases } from "external/mongo/db"; +import type { ICollection } from "monk"; +import type { Game } from "tachi-common"; // im installing an entire library for rm rf... @@ -25,37 +28,37 @@ const DATA_DIR = path.join(__dirname, "./mock-db"); const CACHE: Record> = {}; // eslint-disable-next-line @typescript-eslint/no-explicit-any -async function ResetState(data: Array, collection: any) { +async function ResetState(data: Array, collection: ICollection) { await collection.remove({}); await collection.insert(data); } -function GetAndCache(filename: string, fileLoc: string) { - let collection; +function GetAndCache( + filename: string, + fileLoc: string +): { data: Array; collection: ICollection } { + let collection: ICollection; if (filename.startsWith("songs-")) { - // @ts-expect-error it's right, but we know what we're doing! - collection = db.songs[filename.split("-")[1]]; + collection = db.songs[filename.split("-")[1] as Game]; } else if (filename.startsWith("charts-")) { - // @ts-expect-error see above - collection = db.charts[filename.split("-")[1]]; + collection = db.charts[filename.split("-")[1] as Game]; + } else if (filename in db) { + collection = db[filename as StaticDatabases]; } else { - // @ts-expect-error see above - collection = db[filename]; - } - - if (!collection) { throw new Error( `Panicked when trying to get collection for ${filename}. Does this collection exist?` ); } - if (CACHE[filename]) { - return { data: CACHE[filename], collection }; + const cacheExists = CACHE[filename]; + + if (cacheExists) { + return { data: cacheExists, collection }; } - const data = JSON.parse(fs.readFileSync(fileLoc, "utf-8")); + const data: unknown = JSON.parse(fs.readFileSync(fileLoc, "utf-8")); if (!Array.isArray(data)) { throw new Error(`Panic, ${filename} not JSONArray?`); @@ -66,7 +69,7 @@ function GetAndCache(filename: string, fileLoc: string) { return { data, collection }; } -let CACHE_FILENAMES: Array; +let CACHE_FILENAMES: Array | undefined; export default async function ResetDBState() { let files; diff --git a/server/src/test-utils/setup.ts b/server/src/test-utils/setup.ts index 0bbbf1323..75ec9495d 100644 --- a/server/src/test-utils/setup.ts +++ b/server/src/test-utils/setup.ts @@ -3,4 +3,5 @@ import { monkDB } from "external/mongo/db"; SetIndexesForDB() .then(monkDB.close) - .then(() => process.exit(0)); + .then(() => process.exit(0)) + .catch(() => process.exit(1)); diff --git a/server/src/test-utils/single-process-snapshot.ts b/server/src/test-utils/single-process-snapshot.ts index d257a5f35..d7fb2b029 100644 --- a/server/src/test-utils/single-process-snapshot.ts +++ b/server/src/test-utils/single-process-snapshot.ts @@ -1,3 +1,4 @@ +import { IsNonEmptyString } from "utils/misc"; import fs from "fs"; import path from "path"; @@ -9,7 +10,7 @@ function ReadSnapshotData() { let snapshots: Snapshots = {}; if (fs.existsSync(SNAP_PATH)) { - snapshots = JSON.parse(fs.readFileSync(SNAP_PATH, "utf-8")); + snapshots = JSON.parse(fs.readFileSync(SNAP_PATH, "utf-8")) as Snapshots; } return snapshots; @@ -22,11 +23,11 @@ export function WriteSnapshotData() { const snapshotData = ReadSnapshotData(); export function TestSnapshot(t: Tap.Test, value: string, testName: string) { - if (process.env.TAP_SNAPSHOT) { + if (IsNonEmptyString(process.env.TAP_SNAPSHOT)) { snapshotData[testName] = value; WriteSnapshotData(); } else { - if (!snapshotData[testName]) { + if (snapshotData[testName] === undefined) { return t.fail(`No snapshot exists for ${testName}. Have you ran pnpm snap?`); } diff --git a/server/src/test-utils/single-process-tap.ts b/server/src/test-utils/single-process-tap.ts index 433dc56b5..6adbd8d11 100644 --- a/server/src/test-utils/single-process-tap.ts +++ b/server/src/test-utils/single-process-tap.ts @@ -9,6 +9,8 @@ const files = glob.sync(path.join(__dirname, "../../", "**/*.test.ts")); process.env.NODE_PATH = path.join(__dirname, "../../"); for (const file of files) { + // Deliberate -- we're doing hackery here. + // eslint-disable-next-line @typescript-eslint/no-require-imports require(file); } diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 7c0c7147e..3c6952757 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -11,7 +11,9 @@ import { ApplyNTimes, RFA } from "utils/misc"; import fs from "fs"; import path from "path"; import type { DryScore } from "lib/score-import/framework/common/types"; +import type { S3Score } from "lib/score-import/import-types/file/solid-state-squad/types"; import type { BarbatosScore } from "lib/score-import/import-types/ir/barbatos/types"; +import type { FervidexScore } from "lib/score-import/import-types/ir/fervidex/types"; import type { KsHookSV6CScore } from "lib/score-import/import-types/ir/kshook-sv6c/types"; import type { LR2HookScore } from "lib/score-import/import-types/ir/lr2hook/types"; import type { USCClientScore } from "server/router/ir/usc/_playtype/types"; @@ -33,7 +35,9 @@ import type { const file = (name: string) => path.join(__dirname, "/test-data", name); -export const GetKTDataJSON = (name: string) => JSON.parse(fs.readFileSync(file(name), "utf-8")); +export const GetKTDataJSON = (name: string) => + JSON.parse(fs.readFileSync(file(name), "utf-8")) as unknown; + export const GetKTDataBuffer = (name: string) => fs.readFileSync(file(name)); export const TestingIIDXSPDryScore: DryScore<"iidx:SP"> = { @@ -149,7 +153,9 @@ export const TestingSDVXScore: ScoreDocument<"sdvx:Single"> = { calculatedData: {}, timeAchieved: 1619454485988, songID: 1, - chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", // albida adv + + // albida adv + chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", highlight: false, isPrimary: true, comment: null, @@ -334,6 +340,20 @@ export const BMSGazerChart: ChartDocument<"bms:7K"> = { tierlistInfo: {}, }; +export const BMSGazerSong: SongDocument<"bms"> = { + id: 27339, + title: "gazer [MANIAQ]", + artist: "scytheleg / obj.siokaze", + data: { + subtitle: null, + subartist: null, + genre: "ELECTRANCE", + tableString: null, + }, + searchTerms: [], + altTitles: [], +}; + export const CHUNITHMBBKKChart: ChartDocument<"chunithm:Single"> = { rgcID: null, chartID: "192b96bdb6150f80ba6412ce02df1249e16c0cb0", @@ -550,7 +570,9 @@ export const TestingIIDXSPMilestoneSub: MilestoneSubscriptionDocument = { wasInstantlyAchieved: false, }; -let KTDATA_CACHE: { songs: Array; charts: Array } | undefined; +let KTDATA_CACHE: + | { songs: Array>; charts: Array> } + | undefined; export async function LoadTachiIIDXData() { let songs; @@ -560,8 +582,11 @@ export async function LoadTachiIIDXData() { songs = KTDATA_CACHE.songs; charts = KTDATA_CACHE.charts; } else { - songs = GetKTDataJSON("./tachi/tachi-songs-iidx.json"); - charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json"); + songs = GetKTDataJSON("./tachi/tachi-songs-iidx.json") as Array>; + charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array< + ChartDocument<"iidx:DP" | "iidx:SP"> + >; + KTDATA_CACHE = { songs, charts }; } @@ -571,7 +596,7 @@ export async function LoadTachiIIDXData() { await db.charts.iidx.insert(charts); } -export const barbScore: BarbatosScore = { +export const MockBarbatosScore: BarbatosScore = { clear_type: 2, did_fail: false, difficulty: 1, @@ -700,3 +725,280 @@ export const FakeNotification: NotificationDocument = { }, }, }; + +export const FervidexStaticBase = { + name: "AIXXE", + qpro: { + body: 189, + face: 138, + hair: 76, + hand: 145, + head: 0, + }, + scores: { + "1000": { + spa: { + clear_type: 4, + ex_score: 1180, + miss_count: 40, + }, + spn: { + clear_type: 7, + ex_score: 158, + miss_count: 0, + }, + }, + "1001": { + dph: { + clear_type: 3, + ex_score: 15, + miss_count: 1, + }, + }, + }, + sp_dan: 15, +}; + +export const FervidexBaseScore: FervidexScore = { + bad: 0, + chart: "spa", + clear_type: 1, + combo_break: 6, + custom: false, + chart_sha256: "asdfasdf", + entry_id: 1000, + ex_score: 68, + fast: 0, + gauge: [100, 50], + ghost: [0, 2], + good: 0, + great: 0, + max_combo: 34, + option: { + gauge: "HARD", + range: "SUDDEN_PLUS", + style: "RANDOM", + }, + pacemaker: { + name: "", + score: 363, + type: "PACEMAKER_A", + }, + pgreat: 34, + poor: 6, + slow: 0, +}; + +export const FervidexBaseGSMScore: FervidexScore = { + bad: 0, + chart: "spa", + clear_type: 1, + combo_break: 6, + dead: { + measure: 18, + note: 40, + }, + entry_id: 1000, + custom: false, + chart_sha256: "asdfasdf", + ex_score: 68, + fast: 0, + gauge: [100, 50], + ghost: [0, 2], + good: 0, + great: 0, + max_combo: 34, + option: { + gauge: "HARD", + range: "SUDDEN_PLUS", + style: "RANDOM", + }, + pacemaker: { + name: "", + score: 363, + type: "PACEMAKER_A", + }, + pgreat: 34, + poor: 6, + slow: 0, + "2dx-gsm": { + EASY: [0, 10], + NORMAL: [0, 20], + HARD: [20, 0], + EX_HARD: [10, 0], + }, +}; + +export const MockBeatorajaBMSScore = { + chart: { + md5: "38616b85332037cc12924f2ae2840262", + sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", + title: "GAZER [MANIAQ]", + subtitle: "", + genre: "TRANCE", + artist: "the dude who made gazer", + subartist: "", + url: "", + appendurl: "", + level: 8, + total: 220, + mode: "BEAT_7K", + lntype: 0, + judge: 100, + minbpm: 135, + maxbpm: 135, + notes: 568, + hasUndefinedLN: false, + hasLN: false, + hasCN: false, + hasHCN: false, + hasMine: false, + hasRandom: false, + hasStop: false, + values: {}, + }, + score: { + sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", + lntype: 0, + player: "unknown", + clear: "Easy", + date: 0, + epg: 332, + lpg: 127, + egr: 65, + lgr: 21, + egd: 2, + lgd: 2, + ebd: 1, + lbd: 0, + epr: 7, + lpr: 11, + ems: 1, + lms: 0, + maxcombo: 223, + notes: 568, + passnotes: 568, + minbp: 20, + option: 2, + assist: 0, + gauge: -1, + deviceType: "BM_CONTROLLER", + judgeAlgorithm: "Combo", + rule: "LR2", + exscore: 1004, + }, + client: "LR2oraja 0.8.0", +}; + +export const MockBeatorajaPMSScore = { + chart: { + md5: "d1253dd56bb2087d0b0d474f0d562aae", + sha256: "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d", + title: "GPMSAZER [MANIAQ]", + subtitle: "", + genre: "Annihilate the living", + artist: "Rocky", + subartist: "", + url: "", + appendurl: "", + level: 8, + total: 220, + mode: "POPN_9K", + lntype: 0, + judge: 100, + minbpm: 135, + maxbpm: 135, + notes: 568, + hasUndefinedLN: false, + hasLN: false, + hasCN: false, + hasHCN: false, + hasMine: false, + hasRandom: false, + hasStop: false, + values: {}, + }, + score: { + sha256: "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d", + lntype: 0, + player: "unknown", + clear: "Easy", + date: 0, + epg: 332, + lpg: 127, + egr: 65, + lgr: 21, + egd: 2, + lgd: 2, + ebd: 1, + lbd: 0, + epr: 7, + lpr: 11, + ems: 1, + lms: 0, + maxcombo: 223, + notes: 568, + passnotes: 568, + minbp: 20, + option: 2, + assist: 0, + gauge: -1, + deviceType: "BM_CONTROLLER", + judgeAlgorithm: "Combo", + rule: "LR2", + exscore: 1004, + }, + client: "beatoraja 0.8.0", +}; + +export const MockParsedS3Score: S3Score = { + id: 187, + diff: "A", + songname: "5.1.1.", + styles: "7th", + exscore: 100, + scorebreakdown: { + justgreats: 25, + greats: 50, + good: 0, + bad: 0, + poor: 4, + }, + mods: {}, + cleartype: "perfect", + date: "2010-10-19 04:54:22", +}; + +export const FakeSmallBatchManual = { + meta: { + game: "iidx", + playtype: "SP", + service: "foobar", + }, + scores: [ + { + score: 500, + lamp: "HARD CLEAR", + matchType: "songTitle", + identifier: "5.1.1.", + difficulty: "ANOTHER", + }, + ], +}; + +export const FakeChunitachiBatchManual = { + meta: { + game: "chunithm", + playtype: "Single", + service: "ChunItachi", + }, + scores: [ + { + score: 900000, + lamp: "CLEAR", + matchType: "songTitle", + identifier: "B.B.K.K.B.K.K.", + difficulty: "BASIC", + }, + ], +}; diff --git a/server/src/test-utils/test-data/barbatos/base.json b/server/src/test-utils/test-data/barbatos/base.json deleted file mode 100644 index cfeb37b8b..000000000 --- a/server/src/test-utils/test-data/barbatos/base.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "difficulty": 1, - "level": 10, - "song_id": 1, - "max_chain": 50, - "critical": 50, - "near_total": 30, - "near_fast": 20, - "near_slow": 10, - "score": 9500000, - "error": 5, - "percent": 81, - "did_fail": false, - "clear_type": 2, - "gauge_type": 0, - "is_skill_analyzer": false -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/beatoraja/base.json b/server/src/test-utils/test-data/beatoraja/base.json deleted file mode 100644 index 5889a6944..000000000 --- a/server/src/test-utils/test-data/beatoraja/base.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "chart": { - "md5": "38616b85332037cc12924f2ae2840262", - "sha256": "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", - "title": "GAZER [MANIAQ]", - "subtitle": "", - "genre": "TRANCE", - "artist": "the dude who made gazer", - "subartist": "", - "url": "", - "appendurl": "", - "level": 8, - "total": 220, - "mode": "BEAT_7K", - "lntype": 0, - "judge": 100, - "minbpm": 135, - "maxbpm": 135, - "notes": 568, - "hasUndefinedLN": false, - "hasLN": false, - "hasCN": false, - "hasHCN": false, - "hasMine": false, - "hasRandom": false, - "hasStop": false, - "values": {} - }, - "score": { - "sha256": "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d", - "lntype": 0, - "player": "unknown", - "clear": "Easy", - "date": 0, - "epg": 332, - "lpg": 127, - "egr": 65, - "lgr": 21, - "egd": 2, - "lgd": 2, - "ebd": 1, - "lbd": 0, - "epr": 7, - "lpr": 11, - "ems": 1, - "lms": 0, - "maxcombo": 223, - "notes": 568, - "passnotes": 568, - "minbp": 20, - "option": 2, - "assist": 0, - "gauge": -1, - "deviceType": "BM_CONTROLLER", - "judgeAlgorithm": "Combo", - "rule": "LR2", - "exscore": 1004 - }, - "client": "LR2oraja 0.8.0" -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/beatoraja/pms-base.json b/server/src/test-utils/test-data/beatoraja/pms-base.json deleted file mode 100644 index 8adbf5a30..000000000 --- a/server/src/test-utils/test-data/beatoraja/pms-base.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "chart": { - "md5": "d1253dd56bb2087d0b0d474f0d562aae", - "sha256": "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d", - "title": "GPMSAZER [MANIAQ]", - "subtitle": "", - "genre": "Annihilate the living", - "artist": "Rocky", - "subartist": "", - "url": "", - "appendurl": "", - "level": 8, - "total": 220, - "mode": "POPN_9K", - "lntype": 0, - "judge": 100, - "minbpm": 135, - "maxbpm": 135, - "notes": 568, - "hasUndefinedLN": false, - "hasLN": false, - "hasCN": false, - "hasHCN": false, - "hasMine": false, - "hasRandom": false, - "hasStop": false, - "values": {} - }, - "score": { - "sha256": "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d", - "lntype": 0, - "player": "unknown", - "clear": "Easy", - "date": 0, - "epg": 332, - "lpg": 127, - "egr": 65, - "lgr": 21, - "egd": 2, - "lgd": 2, - "ebd": 1, - "lbd": 0, - "epr": 7, - "lpr": 11, - "ems": 1, - "lms": 0, - "maxcombo": 223, - "notes": 568, - "passnotes": 568, - "minbp": 20, - "option": 2, - "assist": 0, - "gauge": -1, - "deviceType": "BM_CONTROLLER", - "judgeAlgorithm": "Combo", - "rule": "LR2", - "exscore": 1004 - }, - "client": "beatoraja 0.8.0" -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/eamusement-sdvx-csv/parsed-data.json b/server/src/test-utils/test-data/eamusement-sdvx-csv/parsed-data.json deleted file mode 100644 index cf5ba2784..000000000 --- a/server/src/test-utils/test-data/eamusement-sdvx-csv/parsed-data.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "title": "ALBIDA Powerless Mix", - "difficulty": "ADVANCED", - "level": "10", - "lamp": "EXCESSIVE COMPLETE", - "score": "9310699", - "exscore": "0" -} diff --git a/server/src/test-utils/test-data/fervidex-static/base.json b/server/src/test-utils/test-data/fervidex-static/base.json deleted file mode 100644 index bd0600050..000000000 --- a/server/src/test-utils/test-data/fervidex-static/base.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "AIXXE", - "qpro": { - "body": 189, - "face": 138, - "hair": 76, - "hand": 145, - "head": 0 - }, - "scores": { - "1000": { - "spa": { - "clear_type": 4, - "ex_score": 1180, - "miss_count": 40 - }, - "spn": { - "clear_type": 7, - "ex_score": 158, - "miss_count": 0 - } - }, - "1001": { - "dph": { - "clear_type": 3, - "ex_score": 15, - "miss_count": 1 - } - } - }, - "sp_dan": 15 -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/fervidex/2dxgsm.json b/server/src/test-utils/test-data/fervidex/2dxgsm.json deleted file mode 100644 index 1449eb94b..000000000 --- a/server/src/test-utils/test-data/fervidex/2dxgsm.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "bad": 0, - "chart": "spa", - "clear_type": 1, - "combo_break": 6, - "dead": { - "measure": 18, - "note": 40 - }, - "entry_id": 1000, - "custom": false, - "chart_sha256": "asdfasdf", - "ex_score": 68, - "fast": 0, - "gauge": [ - 100, - 50 - ], - "ghost": [ - 0, - 2 - ], - "good": 0, - "great": 0, - "max_combo": 34, - "option": { - "gauge": "HARD", - "range": "SUDDEN_PLUS", - "style": "RANDOM" - }, - "pacemaker": { - "name": "", - "score": 363, - "type": "PACEMAKER_A" - }, - "pgreat": 34, - "poor": 6, - "slow": 0, - "2dx-gsm": { - "EASY": [ - 0, - 10 - ], - "NORMAL": [ - 0, - 20 - ], - "HARD": [ - 20, - 0 - ], - "EX_HARD": [ - 10, - 0 - ] - } -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/fervidex/base.json b/server/src/test-utils/test-data/fervidex/base.json deleted file mode 100644 index 112b55518..000000000 --- a/server/src/test-utils/test-data/fervidex/base.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "bad": 0, - "chart": "spa", - "clear_type": 1, - "combo_break": 6, - "custom": false, - "chart_sha256": "asdfasdf", - "entry_id": 1000, - "ex_score": 68, - "fast": 0, - "gauge": [ - 100, - 50 - ], - "ghost": [ - 0, - 2 - ], - "good": 0, - "great": 0, - "max_combo": 34, - "option": { - "gauge": "HARD", - "range": "SUDDEN_PLUS", - "style": "RANDOM" - }, - "pacemaker": { - "name": "", - "score": 363, - "type": "PACEMAKER_A" - }, - "pgreat": 34, - "poor": 6, - "slow": 0 -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/mer/merscore.json b/server/src/test-utils/test-data/mer/merscore.json deleted file mode 100644 index d956ec7c2..000000000 --- a/server/src/test-utils/test-data/mer/merscore.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "music_id": 1000, - "play_type": "SINGLE", - "diff_type": "ANOTHER", - "score": 1000, - "miss_count": 21, - "clear_type": "CLEAR", - "update_time": "2021-03-24 07:15:22" -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/s3/s3score.json b/server/src/test-utils/test-data/s3/s3score.json deleted file mode 100644 index b9edb16f7..000000000 --- a/server/src/test-utils/test-data/s3/s3score.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": 187, - "diff": "A", - "songname": "5.1.1.", - "styles": "7th", - "exscore": 100, - "scorebreakdown": { - "justgreats": 25, - "greats": 50, - "good": 0, - "bad": 0, - "poor": 4 - }, - "mods": {}, - "cleartype": "perfect", - "date": "2010-10-19 04:54:22" -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/tachi/bms-gazer-chart.json b/server/src/test-utils/test-data/tachi/bms-gazer-chart.json deleted file mode 100644 index 83defc9a6..000000000 --- a/server/src/test-utils/test-data/tachi/bms-gazer-chart.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "songID": 27339, - "chartID": "88eb6cc5683e2740cbd07f588a5f3db1db8d467b", - "rgcID": null, - "data": { - "notecount": 2256, - "hashMD5": "38616b85332037cc12924f2ae2840262", - "hashSHA256": "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d" - }, - "level": "?", - "levelNum": 0, - "difficulty": "CUSTOM", - "playtype": "7K", - "isPrimary": true, - "versions": [] -} \ No newline at end of file diff --git a/server/src/test-utils/test-data/tachi/bms-gazer-song.json b/server/src/test-utils/test-data/tachi/bms-gazer-song.json deleted file mode 100644 index 8cdb4ac1e..000000000 --- a/server/src/test-utils/test-data/tachi/bms-gazer-song.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "id": 27339, - "title": "gazer [MANIAQ]", - "artist": "scytheleg / obj.siokaze", - "data": { - "subtitle": null, - "subartist": null, - "genre": "ELECTRANCE" - }, - "searchTerms": [], - "altTitles": [] -} \ No newline at end of file diff --git a/server/src/utils/calculations/recalc-sessions.ts b/server/src/utils/calculations/recalc-sessions.ts index 35af43646..742dd85d0 100644 --- a/server/src/utils/calculations/recalc-sessions.ts +++ b/server/src/utils/calculations/recalc-sessions.ts @@ -24,8 +24,8 @@ export async function RecalcSessions(filter = {}) { try { c = CreateSessionCalcData(session.game, session.playtype, scores); } catch (err) { - logger.error(`${session.game} (${session.playtype}) failed.`); - logger.warn(`Destroying session.`); + logger.error(`Recalcing ${session.game} (${session.playtype}) failed.`, { err }); + logger.warn(`Destroying session!`); await db.sessions.remove({ sessionID: session.sessionID }); continue; } diff --git a/server/src/utils/efficient-db-iterate.ts b/server/src/utils/efficient-db-iterate.ts index de168d3de..91702192a 100644 --- a/server/src/utils/efficient-db-iterate.ts +++ b/server/src/utils/efficient-db-iterate.ts @@ -14,7 +14,6 @@ export async function EfficientDBIterate( ) { let i = 0; - // eslint-disable-next-line no-constant-condition while (true) { logger.info(`Running on ${i} - ${i + bucketSize} documents.`); // eslint-disable-next-line no-await-in-loop diff --git a/server/src/utils/folder.ts b/server/src/utils/folder.ts index 800aae547..43f818a65 100644 --- a/server/src/utils/folder.ts +++ b/server/src/utils/folder.ts @@ -40,39 +40,54 @@ export async function ResolveFolderToCharts( let songs: Array | null = null; let charts: Array; - if (folder.type === "static") { - charts = await db.charts[folder.game].find( - deepmerge(filter, { - playtype: folder.playtype, // mandatory - chartID: { $in: folder.data }, - }) - ); - } else if (folder.type === "songs") { - songs = await db.songs[folder.game].find(folder.data); + switch (folder.type) { + case "static": { + charts = await db.charts[folder.game].find( + deepmerge(filter, { + // Specifying playtype is mandatory, don't want to catch other charts. + playtype: folder.playtype, + chartID: { $in: folder.data }, + }) + ); + break; + } - charts = await db.charts[folder.game].find( - deepmerge(filter, { - playtype: folder.playtype, - songID: { $in: songs.map((e) => e.id) }, - }) - ); - } else if (folder.type === "charts") { - const folderDataTransposed = TransposeFolderData(folder.data); + case "songs": { + songs = await db.songs[folder.game].find(folder.data); - logger.debug(`Transposed folder data in resolve-folder-to-charts.`, { - folder, - folderDataTransposed, - }); + charts = await db.charts[folder.game].find( + deepmerge(filter, { + playtype: folder.playtype, + songID: { $in: songs.map((e) => e.id) }, + }) + ); + break; + } - const fx = deepmerge.all([filter, { playtype: folder.playtype }, folderDataTransposed]); + case "charts": { + const folderDataTransposed = TransposeFolderData(folder.data); - charts = await db.charts[folder.game].find(fx); - } else { - // @ts-expect-error This is already a weird scenario. Shouldn't fail, though. - logger.error(`Invalid folder at ${folder.folderID}. Cannot resolve.`, { folder }); + logger.debug(`Transposed folder data in resolve-folder-to-charts.`, { + folder, + folderDataTransposed, + }); - // @ts-expect-error See above - throw new Error(`Invalid folder ${folder.folderID}. Cannot resolve.`); + const fx = deepmerge.all([filter, { playtype: folder.playtype }, folderDataTransposed]); + + charts = await db.charts[folder.game].find(fx); + break; + } + + default: { + logger.error( + `Invalid folder at ${(folder as FolderDocument).folderID}. Cannot resolve.`, + { folder } + ); + + throw new Error( + `Invalid folder ${(folder as FolderDocument).folderID}. Cannot resolve.` + ); + } } if (getSongs) { @@ -98,10 +113,14 @@ export async function ResolveFolderToCharts( export function TransposeFolderData(obj: Record) { const transposedObj: Record = {}; - for (const key in obj) { + for (const key of Object.keys(obj)) { const transposedKey = key.replace(/~/gu, "$").replace(/¬/gu, "."); - if (typeof obj[key] === "object" && !Array.isArray(obj[key]) && obj[key]) { + if ( + typeof obj[key] === "object" && + !Array.isArray(obj[key]) && + (obj[key] as object | null) + ) { transposedObj[transposedKey] = TransposeFolderData(obj[key] as Record); } else { transposedObj[transposedKey] = obj[key]; @@ -236,9 +255,8 @@ export function CalculateLampDistribution(pbs: Array) { const lampDist: Partial> = {}; for (const pb of pbs) { - if (lampDist[pb.scoreData.lamp]) { - // @ts-expect-error ??? - lampDist[pb.scoreData.lamp]++; + if (lampDist[pb.scoreData.lamp] !== undefined) { + lampDist[pb.scoreData.lamp]!++; } else { lampDist[pb.scoreData.lamp] = 1; } @@ -251,9 +269,8 @@ export function CalculateGradeDistribution(pbs: Array) { const gradeDist: Partial> = {}; for (const pb of pbs) { - if (gradeDist[pb.scoreData.grade]) { - // @ts-expect-error ??? - gradeDist[pb.scoreData.grade]++; + if (gradeDist[pb.scoreData.grade] !== undefined) { + gradeDist[pb.scoreData.grade]!++; } else { gradeDist[pb.scoreData.grade] = 1; } diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 815147b12..967ed9d4c 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -255,3 +255,7 @@ export function NotNullish(maybeValue: T | null | undefined): T { export function IsNullish(maybeValue: T | null | undefined): maybeValue is null | undefined { return maybeValue === null || maybeValue === undefined; } + +export function IsRecord(maybeRecord: unknown): maybeRecord is Record { + return typeof maybeRecord === "object" && maybeRecord !== null; +} diff --git a/server/src/utils/naive-csv-parser.ts b/server/src/utils/naive-csv-parser.ts index f95908124..5b214cc19 100644 --- a/server/src/utils/naive-csv-parser.ts +++ b/server/src/utils/naive-csv-parser.ts @@ -55,7 +55,7 @@ export function NaiveCSVParse(csvBuffer: Buffer, logger: KtLogger) { const rawRows = []; - for (const [rowNumber, data] of Object.entries(csvData)) { + for (const [rowNumber, data] of Object.entries(csvData).slice(1)) { // @security: This should probably be safetied from DOSing const cells = data.split(","); diff --git a/server/src/utils/prudence.ts b/server/src/utils/prudence.ts index 213860a9d..61c585197 100644 --- a/server/src/utils/prudence.ts +++ b/server/src/utils/prudence.ts @@ -5,7 +5,9 @@ export function FormatPrError(err: PrudenceError, foreword = "Error") { const receivedText = typeof err.userVal === "object" && err.userVal !== null ? "" - : ` | Received ${err.userVal} [${err.userVal === null ? "null" : typeof err.userVal}]`; + : ` | Received ${String(err.userVal)} [${ + err.userVal === null ? "null" : typeof err.userVal + }]`; return `${foreword}: ${err.keychain ?? "null"} | ${err.message}${receivedText}.`; } diff --git a/server/src/utils/queries/charts.ts b/server/src/utils/queries/charts.ts index 16af25c08..bee54a6f0 100644 --- a/server/src/utils/queries/charts.ts +++ b/server/src/utils/queries/charts.ts @@ -297,30 +297,32 @@ export async function FindChartsOnPopularity( } >; - const scoreCounts = await db[scoreCollection].aggregate([ - { - $match: { chartID: { $in: charts.map((e) => e.chartID) } }, - }, - { - $group: { - _id: "$chartID", - count: { $sum: 1 }, + const scoreCounts: Array<{ _id: string; count: integer }> = await db[scoreCollection].aggregate( + [ + { + $match: { chartID: { $in: charts.map((e) => e.chartID) } }, }, - }, - { - $sort: { - count: -1, + { + $group: { + _id: "$chartID", + count: { $sum: 1 }, + }, }, - }, - { - $skip: skip, - }, - { - $limit: limit, - }, - ]); + { + $sort: { + count: -1, + }, + }, + { + $skip: skip, + }, + { + $limit: limit, + }, + ] + ); - const scoreCountMap = new Map(); + const scoreCountMap = new Map(); for (const sc of scoreCounts) { scoreCountMap.set(sc._id, sc.count); diff --git a/server/src/utils/session.ts b/server/src/utils/session.ts index 375a749d2..b16fc5859 100644 --- a/server/src/utils/session.ts +++ b/server/src/utils/session.ts @@ -1,6 +1,5 @@ import db from "external/mongo/db"; -import type { Condition } from "mongodb"; -import type { ScoreDocument, SessionDocument, SessionScoreInfo } from "tachi-common"; +import type { ScoreDocument, SessionDocument } from "tachi-common"; /** * Returns all the score documents inside a session. @@ -18,7 +17,6 @@ export function GetScoresFromSession(session: SessionDocument) { */ export function GetSessionFromScore(score: ScoreDocument) { return db.sessions.findOne({ - // ??? another bug in monks types i think - scoreInfo: { scoreID: score.scoreID } as Condition, + "scoreInfo.scoreID": score.scoreID, }); } diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index b8236c094..ac352baf0 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -27,6 +27,19 @@ declare module "express-session" { } } +declare module "express-serve-static-core" { + export interface Request { + // eslint-disable-next-line lines-around-comment + // KNOWN BUG IN TS-ESLINT. + /** + * This is a type-safe variant of "req.safeBody". + * "req.safeBody" is 'any' by default, which makes it exceptionally difficult + * to use in our codebase (due to the strict hollis rules.) + */ + safeBody: Record; + } +} + export interface TachiSessionData { user: PublicUserDocument; settings: UserSettings; diff --git a/server/src/utils/user.ts b/server/src/utils/user.ts index 3cf80a490..0ca5d2f17 100644 --- a/server/src/utils/user.ts +++ b/server/src/utils/user.ts @@ -142,7 +142,7 @@ export function FormatUserDoc(userdoc: PublicUserDocument) { export async function GetUsersRanking(stats: UserGameStats) { const gptConfig = GetGamePTConfig(stats.game, stats.playtype); - const aggRes = await db["game-stats"].aggregate([ + const aggRes: [{ _id: null; ranking: integer }] = await db["game-stats"].aggregate([ { $match: { game: stats.game, @@ -170,7 +170,7 @@ export async function GetUsersRanking(stats: UserGameStats) { }, ]); - return (aggRes[0].ranking + 1) as integer; + return aggRes[0].ranking + 1; } export function GetUGPTPlaycount(userID: integer, game: Game, playtype: Playtype) { @@ -257,7 +257,7 @@ export async function IsRequesterAdmin(request: APITokenDocument) { return false; } - if (!request.userID) { + if (request.userID === null) { return false; }