diff --git a/server/package.json b/server/package.json index 2b899932a..015f9bd25 100644 --- a/server/package.json +++ b/server/package.json @@ -48,7 +48,6 @@ "csv-parse": "^4.15.4", "deepmerge": "^4.2.2", "dotenv": "^8.2.0", - "escape-string-regexp": "^1.0.5", "express": "^4.17.1", "express-async-errors": "^3.1.1", "express-session": "^1.17.1", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index fe0df6aab..3b6d56dce 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -21,7 +21,6 @@ specifiers: csv-parse: ^4.15.4 deepmerge: ^4.2.2 dotenv: ^8.2.0 - escape-string-regexp: ^1.0.5 eslint: ^7.25.0 eslint-config-prettier: ^8.3.0 eslint-plugin-import: ^2.22.1 @@ -57,7 +56,6 @@ dependencies: csv-parse: 4.15.4 deepmerge: 4.2.2 dotenv: 8.2.0 - escape-string-regexp: 1.0.5 express: 4.17.1 express-async-errors: 3.1.1_express@4.17.1 express-session: 1.17.1 @@ -1588,6 +1586,7 @@ packages: /escape-string-regexp/1.0.5: resolution: {integrity: sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=} engines: {node: '>=0.8.0'} + dev: true /escape-string-regexp/2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} diff --git a/server/scripts/__KT_DATABASE_MIGRATION/scores.ts b/server/scripts/__KT_DATABASE_MIGRATION/scores.ts index 0625b50f7..52259ca9d 100644 --- a/server/scripts/__KT_DATABASE_MIGRATION/scores.ts +++ b/server/scripts/__KT_DATABASE_MIGRATION/scores.ts @@ -90,15 +90,15 @@ function ConvertFn(c: any): ScoreDocument | null { } if (base.game === "iidx") { - // @ts-expect-error fuck you + // @ts-expect-error nope if (base.scoreData.hitMeta.gauge > 200) { - // @ts-expect-error fuck you + // @ts-expect-error nope base.scoreData.hitMeta.gauge = null; } - // @ts-expect-error fuck you + // @ts-expect-error nope if (base.scoreData.hitMeta.gaugeHistory) { - // @ts-expect-error fuck you + // @ts-expect-error nope base.scoreData.hitMeta.gaugeHistory = base.scoreData.hitMeta.gaugeHistory.map((e) => e > 200 ? null : e ); diff --git a/server/src/core/escape-string-regexp.ts b/server/src/core/escape-string-regexp.ts new file mode 100644 index 000000000..c0d189afe --- /dev/null +++ b/server/src/core/escape-string-regexp.ts @@ -0,0 +1,14 @@ +// https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js +// the developer of this has migrated everything to Force ES6 style modules, +// which really really messes with a lot of the ecosystem. +// shim. + +export default function escapeStringRegexp(string: string) { + if (typeof string !== "string") { + throw new TypeError("Expected a string"); + } + + // Escape characters with special meaning either inside or outside character sets. + // Use a simple backslash escape when it's always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns' stricter grammar. + return string.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&").replace(/-/gu, "\\x2d"); +} diff --git a/server/src/score-import/import-types/file/json_batch-manual/parser.test.ts b/server/src/score-import/import-types/file/json_batch-manual/parser.test.ts index 83f402d3c..a994ad489 100644 --- a/server/src/score-import/import-types/file/json_batch-manual/parser.test.ts +++ b/server/src/score-import/import-types/file/json_batch-manual/parser.test.ts @@ -3,6 +3,9 @@ import { CloseMongoConnection } from "../../../../db/db"; import CreateLogCtx from "../../../../logger"; import ScoreImportFatalError from "../../../framework/score-importing/score-import-error"; import ParserFn from "./parser"; +import { BatchManual, BatchManualScore } from "./types"; +import escapeRegex from "../../../../core/escape-string-regexp"; +import deepmerge from "deepmerge"; const fileify = (obj: any) => ({ @@ -10,8 +13,37 @@ const fileify = (obj: any) => filename: "fileify.json", } as Express.Multer.File); +const mockErr = (...msg: string[]) => + (({ + statusCode: 400, + message: new RegExp(msg.map((e) => `${escapeRegex(e)}.*`).join(""), "u"), + name: "Error", + } as unknown) as ScoreImportFatalError); + const logger = CreateLogCtx("parser.test.ts"); +const baseBatchManual = { + body: [], + head: { service: "foo", game: "iidx" }, +}; + +const baseBatchManualScore = { + score: 1000, + lamp: "HARD CLEAR", + matchType: "songID", + identifier: "123", + playtype: "SP", + difficulty: "ANOTHER", +}; + +function dm(sc: any) { + return deepmerge( + baseBatchManual, + { body: [deepmerge(baseBatchManualScore, sc)] }, + { arrayMerge: (r, c) => c } + ); +} + t.test("#ParserFn", (t) => { t.test("Non-Object", (t) => { t.throws( @@ -84,7 +116,7 @@ t.test("#ParserFn", (t) => { () => ParserFn(fileify({ body: [], head: { service: "1", game: "iidx" } }), {}, logger), new ScoreImportFatalError( 400, - "Invalid BATCH-MANUAL (head.service | Expected a string with length between 3 and 15. | Received 1)" + "Invalid BATCH-MANUAL: head.service | Expected a string with length between 3 and 15. | Received 1 [string]." ), "Should throw an error." ); @@ -93,7 +125,7 @@ t.test("#ParserFn", (t) => { () => ParserFn(fileify({ body: [], head: { service: 1, game: "iidx" } }), {}, logger), new ScoreImportFatalError( 400, - "Invalid BATCH-MANUAL (head.service | Expected a string with length between 3 and 15. | Received 1)" + "Invalid BATCH-MANUAL: head.service | Expected a string with length between 3 and 15. | Received 1 [number]." ), "Should throw an error." ); @@ -122,6 +154,199 @@ t.test("#ParserFn", (t) => { t.end(); }); + t.test("Valid BATCH-MANUAL", (t) => { + let res = ParserFn( + fileify({ + body: [ + { + score: 1000, + lamp: "HARD CLEAR", + matchType: "songID", + identifier: "123", + playtype: "SP", + difficulty: "ANOTHER", + }, + { + score: 1000, + lamp: "HARD CLEAR", + matchType: "kamaitachiSongID", + identifier: "123", + playtype: "DP", + difficulty: "HYPER", + }, + { + score: 1000, + lamp: "HARD CLEAR", + matchType: "songTitle", + identifier: "5.1.1.", + }, + { + score: 1000, + lamp: "HARD CLEAR", + matchType: "title", + identifier: "5.1.1.", + }, + ], + head: { service: "foo", game: "iidx" }, + } as BatchManual), + {}, + logger + ); + + t.hasStrict(res, { + game: "iidx", + context: { + service: "foo", + game: "iidx", + version: null, + }, + iterable: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + t.end(); + }); + + t.test("Invalid BATCH-MANUAL", (t) => { + t.test("Invalid Lamp For Game", (t) => { + let fn = () => + ParserFn( + fileify({ + body: [ + { + score: 1000, + lamp: "ALL JUSTICE", // not an iidx lamp + matchType: "songID", + identifier: "123", + playtype: "SP", + difficulty: "ANOTHER", + }, + ], + head: { service: "foo", game: "iidx" }, + }), + {}, + logger + ); + + t.throws( + fn, + new ScoreImportFatalError( + 400, + "Invalid BATCH-MANUAL: body[0].lamp | Expected any of NO PLAY, FAILED, ASSIST CLEAR, EASY CLEAR, CLEAR, HARD CLEAR, EX HARD CLEAR, FULL COMBO. | Received ALL JUSTICE [string]." + ) + ); + + t.end(); + }); + + t.test("Non-numeric score", (t) => { + let fn = () => ParserFn(fileify(dm({ score: "123" })), {}, logger); + + t.throws( + fn, + new ScoreImportFatalError( + 400, + "Invalid BATCH-MANUAL: body[0].score | Expected number. | Received 123 [string]." + ) + ); + + t.end(); + }); + + t.test("Invalid timeAchieved", (t) => { + let fn = () => ParserFn(fileify(dm({ timeAchieved: "string" })), {}, logger); + + t.throws( + fn, + new ScoreImportFatalError( + 400, + "Invalid BATCH-MANUAL: body[0].timeAchieved | Expected a number greater than 1 Trillion - did you pass unix seconds instead of miliseconds? | Received string [string]." + ) + ); + + let fn2 = () => + ParserFn(fileify(dm({ timeAchieved: 1_620_768_609_637 / 1000 })), {}, logger); + + t.throws( + fn2, + new ScoreImportFatalError( + 400, + "Invalid BATCH-MANUAL: body[0].timeAchieved | Expected a number greater than 1 Trillion - did you pass unix seconds instead of miliseconds? | Received 1620768609.637 [number]." + ), + "Should throw if timeAchieved is less than 10_000_000_000." + ); + + t.end(); + }); + + t.test("Invalid Playtype", (t) => { + // this is not a valid playtype for IIDX + let fn = () => ParserFn(fileify(dm({ playtype: "Single" })), {}, logger); + + t.throws( + fn, + new ScoreImportFatalError( + 400, + "Invalid BATCH-MANUAL: body[0].playtype | Expected any of SP, DP. | Received Single [string]." + ) + ); + + t.end(); + }); + + t.test("Invalid Identifier", (t) => { + // this is not a valid playtype for IIDX + let fn = () => ParserFn(fileify(dm({ identifier: null })), {}, logger); + + t.throws(fn, mockErr("body[0].identifier | Expected string", "Received null [null]")); + + t.end(); + }); + + t.test("Invalid MatchType", (t) => { + let fn = () => ParserFn(fileify(dm({ matchType: "Invalid_MatchType" })), {}, logger); + + t.throws( + fn, + mockErr( + "body[0].matchType | Expected any of", + "Received Invalid_MatchType [string]" + ) + ); + + t.end(); + }); + + t.test("Invalid HitData", (t) => { + let fn = () => ParserFn(fileify(dm({ hitData: { not_key: 123 } })), {}, logger); + + t.throws(fn, mockErr("body[0].hitData | Invalid Key not_key")); + + let fn2 = () => ParserFn(fileify(dm({ hitData: { pgreat: "123" } })), {}, logger); + + t.throws( + fn2, + mockErr("body[0].hitData | Key pgreat had an invalid value of 123 [string]") + ); + + t.end(); + }); + + t.test("Invalid HitMeta", (t) => { + let fn = () => ParserFn(fileify(dm({ hitMeta: { not_key: 123 } })), {}, logger); + + t.throws(fn, mockErr("body[0].hitMeta | Unexpected")); + + let fn2 = () => ParserFn(fileify(dm({ hitMeta: { bp: -1 } })), {}, logger); + + t.throws(fn2, mockErr("body[0].hitMeta.bp | Expected a positive integer")); + + t.end(); + }); + + t.end(); + }); + t.end(); }); diff --git a/server/src/score-import/import-types/file/json_batch-manual/parser.ts b/server/src/score-import/import-types/file/json_batch-manual/parser.ts index 0f8d54c55..80b362a4d 100644 --- a/server/src/score-import/import-types/file/json_batch-manual/parser.ts +++ b/server/src/score-import/import-types/file/json_batch-manual/parser.ts @@ -16,9 +16,9 @@ import deepmerge from "deepmerge"; const optNull = (v: ValidSchemaValue) => p.optional(p.nullable(v)); const BaseValidHitMeta = { - fast: p.isPositiveInteger, - slow: p.isPositiveInteger, - maxCombo: p.isPositiveInteger, + fast: optNull(p.isPositiveInteger), + slow: optNull(p.isPositiveInteger), + maxCombo: optNull(p.isPositiveInteger), }; const PR_HitMeta = (game: Game): PrudenceSchema => { @@ -73,7 +73,11 @@ const PR_BatchManualScore = (game: Game): PrudenceSchema => ({ difficulty: "*?string", // this is checked in converting instead // september 9th 2001 - this saves people not // reading the documentation. - timeAchieved: optNull(p.gt(1_000_000_000_000)), + timeAchieved: optNull( + (self) => + (typeof self === "number" && self > 1_000_000_000_000) || + "Expected a number greater than 1 Trillion - did you pass unix seconds instead of miliseconds?" + ), hitData: optNull((self) => { if (typeof self !== "object" || self === null) { return "Not a valid object."; @@ -81,13 +85,15 @@ const PR_BatchManualScore = (game: Game): PrudenceSchema => ({ for (const key in self) { if (!validHitData[game].includes(key)) { - return `Invalid Key ${key}. Expected any of ${validHitData[game].toString()}`; + return `Invalid Key ${key}. Expected any of ${validHitData[game].join(", ")}`; } // @ts-expect-error shush let v = self[key]; - if (!Number.isSafeInteger(v)) { - return `Key ${key} had an invalid value of ${v}.`; + if (!Number.isSafeInteger(v) || v < 0) { + return `Key ${key} had an invalid value of ${v} [${ + v === null ? "null" : typeof v + }]`; } } @@ -172,7 +178,14 @@ function ParseBatchManual( if (err) { throw new ScoreImportFatalError( 400, - `Invalid BATCH-MANUAL (${err.keychain} | ${err.message} | Received ${err.userVal})` + // ouch prettier + `Invalid BATCH-MANUAL: ${err.keychain} | ${err.message}${ + typeof err.userVal === "object" && err.userVal !== null + ? "" + : ` | Received ${err.userVal} [${ + err.userVal === null ? "null" : typeof err.userVal + }]` + }.` ); }