From 3947154e37bc0e04026089ed73a6ce3e9b70bbc9 Mon Sep 17 00:00:00 2001 From: zkldi Date: Tue, 8 Jun 2021 10:19:42 +0100 Subject: [PATCH] Properly store replays sent with POST /replays. Fixes #113 --- server/package.json | 4 + server/pnpm-lock.yaml | 33 +++++++- server/src/lib/cdn/cdn.ts | 10 ++- .../calculated-data/calculated-data.test.ts | 2 +- .../framework/calculated-data/stats.test.ts | 2 +- .../framework/goals/goals.test.ts | 6 +- .../src/server/router/api/v1/import/router.ts | 15 ++-- .../src/server/router/ir/usc/router.test.ts | 79 +++++++++++++++++- server/src/server/router/ir/usc/router.ts | 12 ++- server/src/test-utils/fake-session.ts | 2 +- server/src/test-utils/resets.ts | 14 ++++ server/src/test-utils/test-data.ts | 2 +- .../test-utils/test-data/usc/replayfile.urf | Bin 0 -> 31204 bytes 13 files changed, 160 insertions(+), 21 deletions(-) create mode 100644 server/src/test-utils/test-data/usc/replayfile.urf diff --git a/server/package.json b/server/package.json index 08344d6f3..afa678b5c 100644 --- a/server/package.json +++ b/server/package.json @@ -18,10 +18,12 @@ "@types/connect-redis": "^0.0.16", "@types/express": "^4.17.12", "@types/express-session": "^1.17.3", + "@types/mkdirp": "^1.0.1", "@types/multer": "^1.4.5", "@types/node": "^15.12.0", "@types/node-fetch": "^2.5.10", "@types/range-parser": "^1.2.3", + "@types/rimraf": "^3.0.0", "@types/supertest": "^2.0.11", "@types/tap": "^15.0.2", "@types/uuid": "^8.3.0", @@ -53,12 +55,14 @@ "fast-json-stable-hash": "^1.0.2", "fast-xml-parser": "^3.19.0", "kamaitachi-common": "github:zkldi/Kamaitachi-common", + "mkdirp": "^1.0.4", "mongodb": "3.6.3", "monk": "^7.3.4", "multer": "^1.4.2", "node-fetch": "^2.6.1", "prudence": "^0.9.6", "redis": "^3.1.2", + "rimraf": "^3.0.2", "typescript": "^4.3.2", "winston": "^3.3.3" }, diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index e55d64764..6f200beff 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -5,10 +5,12 @@ specifiers: '@types/connect-redis': ^0.0.16 '@types/express': ^4.17.12 '@types/express-session': ^1.17.3 + '@types/mkdirp': ^1.0.1 '@types/multer': ^1.4.5 '@types/node': ^15.12.0 '@types/node-fetch': ^2.5.10 '@types/range-parser': ^1.2.3 + '@types/rimraf': ^3.0.0 '@types/supertest': ^2.0.11 '@types/tap': ^15.0.2 '@types/uuid': ^8.3.0 @@ -32,6 +34,7 @@ specifiers: fast-json-stable-hash: ^1.0.2 fast-xml-parser: ^3.19.0 kamaitachi-common: github:zkldi/Kamaitachi-common + mkdirp: ^1.0.4 mongodb: 3.6.3 monk: ^7.3.4 multer: ^1.4.2 @@ -40,6 +43,7 @@ specifiers: prettier: ^2.3.0 prudence: ^0.9.6 redis: ^3.1.2 + rimraf: ^3.0.2 supertest: ^6.1.3 tap: ^15.0.9 ts-node: ^10.0.0 @@ -61,12 +65,14 @@ dependencies: fast-json-stable-hash: 1.0.2 fast-xml-parser: 3.19.0 kamaitachi-common: github.com/zkldi/Kamaitachi-common/ceaccaf38a718a78257d48a36a0c524faa1c5132 + mkdirp: 1.0.4 mongodb: 3.6.3 monk: 7.3.4 multer: 1.4.2 node-fetch: 2.6.1 prudence: 0.9.6 redis: 3.1.2 + rimraf: 3.0.2 typescript: 4.3.2 winston: 3.3.3 @@ -75,10 +81,12 @@ devDependencies: '@types/connect-redis': 0.0.16 '@types/express': 4.17.12 '@types/express-session': 1.17.3 + '@types/mkdirp': 1.0.1 '@types/multer': 1.4.5 '@types/node': 15.12.0 '@types/node-fetch': 2.5.10 '@types/range-parser': 1.2.3 + '@types/rimraf': 3.0.0 '@types/supertest': 2.0.11 '@types/tap': 15.0.2 '@types/uuid': 8.3.0 @@ -523,6 +531,13 @@ packages: '@types/serve-static': 1.13.9 dev: true + /@types/glob/7.1.3: + resolution: {integrity: sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==} + dependencies: + '@types/minimatch': 3.0.4 + '@types/node': 15.12.1 + dev: true + /@types/ioredis/4.26.4: resolution: {integrity: sha512-QFbjNq7EnOGw6d1gZZt2h26OFXjx7z+eqEnbCHSrDI1OOLEgOHMKdtIajJbuCr9uO+X9kQQRe7Lz6uxqxl5XKg==} dependencies: @@ -541,6 +556,16 @@ packages: resolution: {integrity: sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==} dev: true + /@types/minimatch/3.0.4: + resolution: {integrity: sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==} + dev: true + + /@types/mkdirp/1.0.1: + resolution: {integrity: sha512-HkGSK7CGAXncr8Qn/0VqNtExEE+PHMWb+qlR1faHMao7ng6P3tAaoWWBMdva0gL5h4zprjIO89GJOLXsMcDm1Q==} + dependencies: + '@types/node': 15.12.1 + dev: true + /@types/mongodb/3.6.17: resolution: {integrity: sha512-9hhgvYPdC5iHyyksPcKCu45gfaAIPQHKHGdvNXu4582DmOZX3wrUJIJPT40o4G1oTKPgpMMFqZglOTjhnYoF+A==} dependencies: @@ -567,7 +592,6 @@ packages: /@types/node/15.12.1: resolution: {integrity: sha512-zyxJM8I1c9q5sRMtVF+zdd13Jt6RU4r4qfhTd7lQubyThvLfx6yYekWSQjGCGV2Tkecgxnlpl/DNlb6Hg+dmEw==} - dev: false /@types/qs/6.9.6: resolution: {integrity: sha512-0/HnwIfW4ki2D8L8c9GVcG5I72s9jP5GSLVF0VIXDW00kmIpA6O33G7a8n59Tmh7Nz0WUC3rSb7PTY/sdW2JzA==} @@ -583,6 +607,13 @@ packages: '@types/node': 15.12.0 dev: true + /@types/rimraf/3.0.0: + resolution: {integrity: sha512-7WhJ0MdpFgYQPXlF4Dx+DhgvlPCfz/x5mHaeDQAKhcenvQP1KCpLQ18JklAqeGMYSAT2PxLpzd0g2/HE7fj7hQ==} + dependencies: + '@types/glob': 7.1.3 + '@types/node': 15.12.1 + dev: true + /@types/serve-static/1.13.9: resolution: {integrity: sha512-ZFqF6qa48XsPdjXV5Gsz0Zqmux2PerNd3a/ktL45mHpa19cuMi/cL8tcxdAx497yRh+QtYPuofjT9oWw9P7nkA==} dependencies: diff --git a/server/src/lib/cdn/cdn.ts b/server/src/lib/cdn/cdn.ts index 9a34a37d6..826bd482f 100644 --- a/server/src/lib/cdn/cdn.ts +++ b/server/src/lib/cdn/cdn.ts @@ -3,6 +3,7 @@ import path from "path"; import { KTCDN_ROOT } from "../env/env"; import CreateLogCtx from "../logger/logger"; import { promisify } from "util"; +import mkdirp from "mkdirp"; const readFilePromise = promisify(fs.readFile); const writeFilePromise = promisify(fs.writeFile); @@ -34,8 +35,13 @@ const WRITE_NO_OVERWRITE = "wx"; * Stores the provided buffer or string as a file at the given location. * @returns Nothing on success. Throws on error. */ -export function StoreCDN(fileLoc: string, data: Buffer | string) { +export async function StoreCDN(fileLoc: string, data: Buffer | string) { logger.debug(`Storing path ${fileLoc}.`); - return writeFilePromise(CDNRoot(fileLoc), data, { flag: WRITE_NO_OVERWRITE }); + const loc = CDNRoot(fileLoc); + + // make the parent folders if they dont exist. else, mkdirp is a no-op. + await mkdirp(path.dirname(loc)); + + return writeFilePromise(loc, data, { flag: WRITE_NO_OVERWRITE }); } diff --git a/server/src/lib/score-import/framework/calculated-data/calculated-data.test.ts b/server/src/lib/score-import/framework/calculated-data/calculated-data.test.ts index 7a9bfa5f3..99eb8bb2f 100644 --- a/server/src/lib/score-import/framework/calculated-data/calculated-data.test.ts +++ b/server/src/lib/score-import/framework/calculated-data/calculated-data.test.ts @@ -12,7 +12,7 @@ import { } from "../../../../test-utils/test-data"; import { CreateCalculatedData, CalculateDataForGamePT } from "./calculated-data"; import deepmerge from "deepmerge"; -import ResetDBState from "../../../../test-utils/reset-db-state"; +import ResetDBState from "../../../../test-utils/resets"; import { CloseAllConnections } from "../../../../test-utils/close-connections"; const logger = CreateLogCtx(__filename); diff --git a/server/src/lib/score-import/framework/calculated-data/stats.test.ts b/server/src/lib/score-import/framework/calculated-data/stats.test.ts index 410c84561..06e182abf 100644 --- a/server/src/lib/score-import/framework/calculated-data/stats.test.ts +++ b/server/src/lib/score-import/framework/calculated-data/stats.test.ts @@ -22,7 +22,7 @@ import { } from "../../../../test-utils/test-data"; import { CloseAllConnections } from "../../../../test-utils/close-connections"; import deepmerge from "deepmerge"; -import ResetDBState from "../../../../test-utils/reset-db-state"; +import ResetDBState from "../../../../test-utils/resets"; t.test("#CalculateBPI", (t) => { t.test("AA BPI tests", (t) => { 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 86d22101f..f04fbadad 100644 --- a/server/src/lib/score-import/framework/goals/goals.test.ts +++ b/server/src/lib/score-import/framework/goals/goals.test.ts @@ -1,6 +1,6 @@ import t from "tap"; import db from "../../../../external/mongo/db"; -import ResetDBState from "../../../../test-utils/reset-db-state"; +import ResetDBState from "../../../../test-utils/resets"; import { GetRelevantFolderGoals, GetRelevantGoals, UpdateGoalsForUser, ProcessGoal } from "./goals"; import { GoalDocument, UserGoalDocument } from "kamaitachi-common"; import { CreateFolderChartLookup } from "../../../../utils/folder"; @@ -253,10 +253,10 @@ t.test("#UpdateGoalsForUser", (t) => { const goal = deepmerge(baseGoalDocument, { criteria: { value: 2 } }); await db.goals.insert(goal); - const userGoal = (deepmerge(baseUserGoalDocument, { + const userGoal = deepmerge(baseUserGoalDocument, { outOf: 2, outOfHuman: "2", - }) as unknown) as UserGoalDocument; + }) as unknown as UserGoalDocument; await db["user-goals"].insert(userGoal); // we dont delete _id here because updategoalsforuser diff --git a/server/src/server/router/api/v1/import/router.ts b/server/src/server/router/api/v1/import/router.ts index 080f314cf..90ce3da33 100644 --- a/server/src/server/router/api/v1/import/router.ts +++ b/server/src/server/router/api/v1/import/router.ts @@ -7,10 +7,16 @@ import CreateLogCtx, { KtLogger } from "../../../../../lib/logger/logger"; import prValidate from "../../../../middleware/prudence-validate"; import { RequireLoggedIn } from "../../../../middleware/require-logged-in"; import ScoreImportFatalError from "../../../../../lib/score-import/framework/score-importing/score-import-error"; - +import { SIXTEEN_MEGABTYES } from "../../../../../lib/constants/filesize"; import { ExpressWrappedScoreImportMain } from "../../../../../lib/score-import/framework/express-wrapper"; import { CreateMulterSingleUploadMiddleware } from "../../../../middleware/multer-upload"; +import ParseEamusementIIDXCSV from "../../../../../lib/score-import/import-types/file/eamusement-iidx-csv/parser"; +import ParseBatchManual from "../../../../../lib/score-import/import-types/file/batch-manual/parser"; +import { ParseSolidStateXML } from "../../../../../lib/score-import/import-types/file/solid-state-squad/parser"; +import { ParseMerIIDX } from "../../../../../lib/score-import/import-types/file/mer-iidx/parser"; +import ParsePLIIIDXCSV from "../../../../../lib/score-import/import-types/file/pli-iidx-csv/parser"; + const logger = CreateLogCtx(__filename); const router: Router = Router({ mergeParams: true }); @@ -68,13 +74,6 @@ router.post( } ); -import ParseEamusementIIDXCSV from "../../../../../lib/score-import/import-types/file/eamusement-iidx-csv/parser"; -import ParseBatchManual from "../../../../../lib/score-import/import-types/file/batch-manual/parser"; -import { ParseSolidStateXML } from "../../../../../lib/score-import/import-types/file/solid-state-squad/parser"; -import { ParseMerIIDX } from "../../../../../lib/score-import/import-types/file/mer-iidx/parser"; -import ParsePLIIIDXCSV from "../../../../../lib/score-import/import-types/file/pli-iidx-csv/parser"; -import { SIXTEEN_MEGABTYES } from "../../../../../lib/constants/filesize"; - /** * Resolves the data from a file upload into an iterable, * The appropriate processing function to map that iterable over, diff --git a/server/src/server/router/ir/usc/router.test.ts b/server/src/server/router/ir/usc/router.test.ts index 4e083b583..b49043b3c 100644 --- a/server/src/server/router/ir/usc/router.test.ts +++ b/server/src/server/router/ir/usc/router.test.ts @@ -2,7 +2,7 @@ import t from "tap"; import db from "../../../../external/mongo/db"; import { CloseAllConnections } from "../../../../test-utils/close-connections"; import mockApi from "../../../../test-utils/mock-api"; -import ResetDBState from "../../../../test-utils/resets"; +import ResetDBState, { ResetCDN } from "../../../../test-utils/resets"; import deepmerge from "deepmerge"; import { PBScoreDocument, ScoreDocument } from "kamaitachi-common"; import { GetKTDataBuffer } from "../../../../test-utils/test-data"; @@ -337,6 +337,7 @@ t.test("GET /charts/:chartHash/leaderboard", (t) => { t.test("POST /replays", (t) => { t.beforeEach(ResetDBState); + t.beforeEach(ResetCDN); t.test("Should successfully upload a file where an identifier matches", async (t) => { await db.scores.insert({ @@ -357,7 +358,7 @@ t.test("POST /replays", (t) => { t.strictSame(res.body, { statusCode: 20, - description: "Uploaded replay.", + description: "Saved replay.", body: null, }); @@ -368,6 +369,80 @@ t.test("POST /replays", (t) => { t.end(); }); + t.test("Should reject a request with no identifier", async (t) => { + await db.scores.insert({ + game: "usc", + userID: 1, + scoreID: "MOCK_IDENTIFIER", + } as ScoreDocument); + + const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); + + const res = await mockApi + .post("/ir/usc/replays") + .attach("replay", replayFile, "replay.urf") + .set("Authorization", "Bearer foo"); + + t.equal(res.status, 200); + + t.strictSame(res.body, { + statusCode: 40, + description: "No Identifier Provided.", + }); + + t.end(); + }); + + t.test("Should reject a request with no file", async (t) => { + await db.scores.insert({ + game: "usc", + userID: 1, + scoreID: "MOCK_IDENTIFIER", + } as ScoreDocument); + + // const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); + + const res = await mockApi + .post("/ir/usc/replays") + .field("identifier", "MOCK_IDENTIFIER") + // .attach("replay", replayFile, "replay.urf") + .set("Authorization", "Bearer foo"); + + t.equal(res.status, 200); + + t.strictSame(res.body, { + statusCode: 40, + description: "No File Provided.", + }); + + t.end(); + }); + + t.test("Should reject a request with an invalid identifier", async (t) => { + await db.scores.insert({ + game: "usc", + userID: 1, + scoreID: "MOCK_IDENTIFIER", + } as ScoreDocument); + + const replayFile = GetKTDataBuffer("./usc/replayfile.urf"); + + const res = await mockApi + .post("/ir/usc/replays") + .field("identifier", "INVALID_IDENTIFIER") + .attach("replay", replayFile, "replay.urf") + .set("Authorization", "Bearer foo"); + + t.equal(res.status, 200); + + t.strictSame(res.body, { + statusCode: 44, + description: "No score corresponds to this identifier.", + }); + + t.end(); + }); + t.end(); }); diff --git a/server/src/server/router/ir/usc/router.ts b/server/src/server/router/ir/usc/router.ts index 187f0e09d..bf2db9de1 100644 --- a/server/src/server/router/ir/usc/router.ts +++ b/server/src/server/router/ir/usc/router.ts @@ -326,6 +326,13 @@ router.post( }); } + if (!req.file) { + return res.status(200).json({ + statusCode: STATUS_CODES.BAD_REQ, + description: "No File Provided.", + }); + } + const correspondingScore = await db.scores.findOne({ userID: req[SYMBOL_KtchiData]!.uscAuthDoc!.userID, game: "usc", @@ -348,8 +355,11 @@ router.post( body: null, }); } catch (err) { + // impossible to test pretty much. + /* istanbul ignore next */ logger.error(`USCIR Replay Store error.`, { err }); - return res.status(500).json({ + /* istanbul ignore next */ + return res.status(200).json({ statusCode: STATUS_CODES.SERVER_ERROR, description: "An error has occured in storing the replay.", }); diff --git a/server/src/test-utils/fake-session.ts b/server/src/test-utils/fake-session.ts index 43e2e00f2..7667f0983 100644 --- a/server/src/test-utils/fake-session.ts +++ b/server/src/test-utils/fake-session.ts @@ -1,4 +1,4 @@ -import ResetDBState from "./reset-db-state"; +import ResetDBState from "./resets"; import CreateLogCtx from "../lib/logger/logger"; import supertest from "supertest"; diff --git a/server/src/test-utils/resets.ts b/server/src/test-utils/resets.ts index 68b9ce056..583e53cc3 100644 --- a/server/src/test-utils/resets.ts +++ b/server/src/test-utils/resets.ts @@ -5,6 +5,9 @@ import db from "../external/mongo/db"; import fs from "fs"; import path from "path"; import CreateLogCtx from "../lib/logger/logger"; +// im installing an entire library for rm rf... +import rimraf from "rimraf"; +import { KTCDN_ROOT } from "../lib/env/env"; const logger = CreateLogCtx(__filename); @@ -70,3 +73,14 @@ export default async function ResetDBState() { await Promise.all(promises); } + +export function ResetCDN() { + return new Promise((resolve, reject) => + rimraf(KTCDN_ROOT, (err) => { + if (err) { + reject(err); + } + resolve(); + }) + ); +} diff --git a/server/src/test-utils/test-data.ts b/server/src/test-utils/test-data.ts index 745581942..fdfd6b87c 100644 --- a/server/src/test-utils/test-data.ts +++ b/server/src/test-utils/test-data.ts @@ -20,7 +20,7 @@ import { } from "../lib/constants/usc-ir"; import { DryScore } from "../lib/score-import/framework/common/types"; import { BarbatosScore } from "../lib/score-import/import-types/ir/barbatos/types"; -import { USCClientScore } from "../server/router/ir/usc/usc"; +import { USCClientScore } from "../server/router/ir/usc/types"; const file = (name: string) => path.join(__dirname, "/test-data", name); diff --git a/server/src/test-utils/test-data/usc/replayfile.urf b/server/src/test-utils/test-data/usc/replayfile.urf new file mode 100644 index 0000000000000000000000000000000000000000..b69c3524d59ac831eb1546f2b992efc53e7878ab GIT binary patch literal 31204 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