diff --git a/server/package.json b/server/package.json index 9c330485a..8cf67ddbb 100644 --- a/server/package.json +++ b/server/package.json @@ -71,7 +71,7 @@ "redis": "3.1.2", "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", - "tachi-common": "0.1.50", + "tachi-common": "0.1.53", "typescript": "4.3.4", "winston": "3.3.3" }, diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 762390c4b..3358746e7 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -52,7 +52,7 @@ specifiers: rimraf: 3.0.2 safe-json-stringify: 1.2.0 supertest: 6.1.3 - tachi-common: 0.1.50 + tachi-common: 0.1.53 tap: 15.0.9 ts-node: 10.0.0 tsconfig-paths: 3.10.1 @@ -85,7 +85,7 @@ dependencies: redis: 3.1.2 rimraf: 3.0.2 safe-json-stringify: 1.2.0 - tachi-common: 0.1.50_ts-node@10.0.0+typescript@4.3.4 + tachi-common: 0.1.53_ts-node@10.0.0+typescript@4.3.4 typescript: 4.3.4 winston: 3.3.3 @@ -3666,8 +3666,8 @@ packages: strip-ansi: 6.0.0 dev: true - /tachi-common/0.1.50_ts-node@10.0.0+typescript@4.3.4: - resolution: {integrity: sha512-F84jmrubIuhUSUZx7sm8aDpm43vklEgXT2j3XMaQoLLuuKIz5BgehMgNNNkJYoeRPvyEj/2Q/Bw46ye+D0q6Dg==} + /tachi-common/0.1.53_ts-node@10.0.0+typescript@4.3.4: + resolution: {integrity: sha512-KdfSo1xY11A/+Wj8cD5CqbExkYvG1p7J4PWNWmlZM57ZlF6yQ9tl6evcSPK9OaN2R391hqFXF5S7LDMQ1fHnrA==} dependencies: monk: 7.3.4 tap: 15.0.9_ts-node@10.0.0+typescript@4.3.4 diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index 44486a5a2..47a3eb42c 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -33,6 +33,7 @@ import { PublicUserDocument, OAuth2ApplicationDocument, integer, + FervidexSettingsDocument, } from "tachi-common"; import monk, { TMiddleware } from "monk"; import CreateLogCtx from "lib/logger/logger"; @@ -175,6 +176,7 @@ const db = { "oauth2-auth-codes": // i've inlined this one because i don't see it appearing anywhere else. monkDB.get<{ code: string; userID: integer; createdOn: number }>("oauth2-auth-codes"), + "fer-settings": monkDB.get("fer-settings"), }; export type StaticDatabases = @@ -205,6 +207,7 @@ export type StaticDatabases = | "user-private-information" | "oauth2-clients" | "oauth2-auth-codes" + | "fer-settings" | "user-settings"; export type Databases = StaticDatabases | `songs-${Game}` | `charts-${Game}`; diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index 9b9946bf9..342c0f557 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -91,6 +91,7 @@ const staticIndexes: Partial> = { ], "user-settings": [index({ userID: 1 }, UNIQUE)], "user-private-information": [index({ userID: 1 }, UNIQUE)], + "fer-settings": [index({ userID: 1 }, UNIQUE)], }; const indexes: Partial> = staticIndexes; diff --git a/server/src/server/router/api/v1/oauth/clients/router.ts b/server/src/server/router/api/v1/oauth/clients/router.ts index 2b7168a99..45a73c80f 100644 --- a/server/src/server/router/api/v1/oauth/clients/router.ts +++ b/server/src/server/router/api/v1/oauth/clients/router.ts @@ -87,8 +87,8 @@ router.post( }); } - const clientID = Random20Hex(); - const clientSecret = Random20Hex(); + const clientID = `CI${Random20Hex()}`; + const clientSecret = `CS${Random20Hex()}`; const clientDoc = { clientID, diff --git a/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.test.ts b/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.test.ts new file mode 100644 index 000000000..fc5db6b54 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.test.ts @@ -0,0 +1,134 @@ +import db from "external/mongo/db"; +import { PublicUserDocument } from "tachi-common"; +import t from "tap"; +import { CreateFakeAuthCookie } from "test-utils/fake-auth"; +import mockApi from "test-utils/mock-api"; +import ResetDBState from "test-utils/resets"; + +t.test("GET /api/v1/users/:userID/integrations/fervidex/s", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should return null if this user has no settings set.", async (t) => { + await db["fer-settings"].remove({}); + + const res = await mockApi + .get("/api/v1/users/1/integrations/fervidex/settings") + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + t.equal(res.body.body, null); + + t.end(); + }); + + t.test("Should return this users settings if they have them.", async (t) => { + await db["fer-settings"].update({ userID: 1 }, { $set: { cards: ["foo", "bar"] } }); + + const res = await mockApi + .get("/api/v1/users/1/integrations/fervidex/settings") + .set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + t.strictSame(res.body.body, { + userID: 1, + forceStaticImport: false, + cards: ["foo", "bar"], + }); + + t.end(); + }); + + t.test("Must require self-key level authentication.", async (t) => { + const res = await mockApi.get("/api/v1/users/1/integrations/fervidex/settings"); + + t.equal(res.statusCode, 401); + + const res2 = await mockApi + .get("/api/v1/users/1/integrations/fervidex/settings") + .set("Authorization", "Bearer fake_api_token"); + + t.equal(res2.statusCode, 403); + + // insert a fake user doc so this doesn't 404 + await db.users.insert({ + id: 2, + username: "foo", + usernameLowercase: "foo", + } as PublicUserDocument); + + const res3 = await mockApi + .get("/api/v1/users/2/integrations/fervidex/settings") + .set("Cookie", cookie); + + t.equal(res3.statusCode, 403); + + t.end(); + }); + + t.end(); +}); + +t.test("PATCH /api/v1/users/:userID/integrations/fervidex/settings", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should update a users settings.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1/integrations/fervidex/settings") + .send({ + cards: ["foo", "bar"], + }) + .set("Cookie", cookie); + + t.strictSame(res.body.body.cards, ["foo", "bar"]); + + const dbRes = await db["fer-settings"].findOne({ userID: 1 }); + + t.strictSame(dbRes?.cards, ["foo", "bar"]); + + t.end(); + }); + + t.test("Should insert a setting filter document if one doesn't exist.", async (t) => { + await db["fer-settings"].remove({}); + + const res = await mockApi + .patch("/api/v1/users/1/integrations/fervidex/settings") + .send({ + cards: ["foo", "bar"], + }) + .set("Cookie", cookie); + + t.strictSame(res.body.body.cards, ["foo", "bar"]); + + const dbRes = await db["fer-settings"].findOne({ userID: 1 }); + + t.strictSame(dbRes?.cards, ["foo", "bar"]); + + t.end(); + }); + + t.test("Should null settings if null is provided.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1/integrations/fervidex/settings") + .send({ + cards: null, + }) + .set("Cookie", cookie); + + t.strictSame(res.body.body.cards, null); + + const dbRes = await db["fer-settings"].findOne({ userID: 1 }); + + t.strictSame(dbRes?.cards, null); + + t.end(); + }); + + t.end(); +}); 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 new file mode 100644 index 000000000..b59f18209 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/fervidex/router.ts @@ -0,0 +1,86 @@ +import { Router } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import prValidate from "server/middleware/prudence-validate"; +import { RequireKamaitachi } from "server/middleware/type-require"; +import { RequireSelfRequestFromUser } from "../../middleware"; +import { optNull } from "utils/prudence"; +import { DeleteUndefinedProps } from "utils/misc"; + +const router: Router = Router({ mergeParams: true }); + +router.use(RequireKamaitachi); +router.use(RequireSelfRequestFromUser); + +/** + * Retrieve your fervidex settings. + * + * @name GET /api/v1/users/:userID/integrations/fervidex/settings + */ +router.get("/settings", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + const settingsDoc = await db["fer-settings"].findOne({ + userID: user.id, + }); + + return res.status(200).json({ + success: true, + description: `Retrieved Fervidex settings.`, + body: settingsDoc ?? null, + }); +}); + +/** + * Update your fervidex configuration. + * + * @param cards - An array of strings to be used as a cards whitelist. + * @param forceStaticImport - Whether or whether not to force a static import on non-INF2 clients. + * + * @name PUT /api/v1/users/:userID/integrations/fervidex/settings + */ +router.patch( + "/settings", + prValidate({ cards: optNull(["string"]), forceStaticImport: "*?boolean" }), + async (req, res) => { + if (req.body.cards && req.body.cards.length > 6) { + return res.status(400).json({ + success: false, + description: `You cannot have more than 6 card filters at once.`, + }); + } + + const user = req[SYMBOL_TachiData]!.requestedUser!; + + const modifyDocument = req.body; + + DeleteUndefinedProps(modifyDocument); + + if (Object.keys(modifyDocument).length === 0) { + return res.status(400).json({ + success: false, + description: `No modifications sent.`, + }); + } + + await db["fer-settings"].update( + { userID: user.id }, + { + $set: modifyDocument, + }, + { + upsert: true, + } + ); + + const settings = await db["fer-settings"].findOne({ userID: user.id }); + + return res.status(200).json({ + success: true, + description: `Successfully updated settings.`, + body: settings, + }); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/integrations/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/router.ts index 918ea9484..bbcf46819 100644 --- a/server/src/server/router/api/v1/users/_userID/integrations/router.ts +++ b/server/src/server/router/api/v1/users/_userID/integrations/router.ts @@ -1,4 +1,5 @@ import { Router } from "express"; +import fervidexRouter from "./fervidex/router"; import arcRouter from "./arc/router"; import kaiKaiTypeRouter from "./kai/_kaiType/router"; @@ -6,5 +7,6 @@ const router: Router = Router({ mergeParams: true }); router.use("/arc", arcRouter); router.use("/kai/:kaiType", kaiKaiTypeRouter); +router.use("/fervidex", fervidexRouter); export default router; diff --git a/server/src/server/router/ir/fervidex/router.test.ts b/server/src/server/router/ir/fervidex/router.test.ts index 335f6ef4f..669d18355 100644 --- a/server/src/server/router/ir/fervidex/router.test.ts +++ b/server/src/server/router/ir/fervidex/router.test.ts @@ -8,6 +8,38 @@ import { GetKTDataJSON } from "test-utils/test-data"; // eslint-disable-next-line @typescript-eslint/no-explicit-any function TestHeaders(url: string, data: any) { + t.test("Should validate against card filters", async (t) => { + await db["fer-settings"].remove({}); + await db["fer-settings"].insert({ + userID: 1, + cards: ["foo"], + forceStaticImport: false, + }); + + const res = await mockApi + .post(url) + .set("Authorization", "Bearer mock_token") + // rootage + .set("X-Software-Model", "LDJ:J:B:A:2020092900") + .set("X-Account-Id", "bar") + .set("User-Agent", "fervidex/1.3.0") + .send(data); + + t.equal(res.body.success, false, "Should reject invalid card."); + + const res2 = await mockApi + .post(url) + .set("Authorization", "Bearer mock_token") + // rootage + .set("X-Software-Model", "LDJ:J:B:A:2020092900") + .set("User-Agent", "fervidex/1.3.0") + .send(data); + + t.equal(res2.body.success, false, "Should reject no card."); + + t.end(); + }); + t.test("Should reject invalid X-Software-Models", async (t) => { let res = await mockApi .post(url) @@ -337,5 +369,53 @@ t.test("POST /ir/fervidex/profile/submit", (t) => { t.end(); }); + t.test("Should allow requests from non INF2 if forceStaticImport is true.", async (t) => { + await db["fer-settings"].update({ userID: 1 }, { $set: { forceStaticImport: true } }); + await db.songs.iidx.remove({}); + await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json")); + await db.charts.iidx.remove({}); + await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json")); + + const res = await mockApi + .post("/ir/fervidex/profile/submit") + .set("Authorization", "Bearer mock_token") + .set("User-Agent", "fervidex/1.3.0") + .set("X-Software-Model", "LDJ:J:B:A:2020092900") + .send(ferStaticBody); + + t.equal(res.body.success, true, "Should be successful"); + + t.equal(res.body.body.errors.length, 0, "Should have 0 failed scores."); + + t.strictSame( + res.body.body.classDeltas, + [ + { + set: "dan", + playtype: "SP", + old: null, + new: 15, + }, + ], + "Should return updated dan deltas." + ); + + const scores = await db.scores.count({ + service: "Fervidex Static", + }); + + t.equal(scores, 3, "Should import 3 scores."); + + const ugs = await db["game-stats"].findOne({ + userID: 1, + game: "iidx", + playtype: "SP", + }); + + t.equal(ugs!.classes.dan, 15, "Should successfully update dan to 9th."); + + t.end(); + }); + t.end(); }); diff --git a/server/src/server/router/ir/fervidex/router.ts b/server/src/server/router/ir/fervidex/router.ts index 624a2dc4b..a3f41a9ea 100644 --- a/server/src/server/router/ir/fervidex/router.ts +++ b/server/src/server/router/ir/fervidex/router.ts @@ -8,8 +8,9 @@ import { ParseFervidexStatic } from "lib/score-import/import-types/ir/fervidex-s import { ParseFervidexSingle } from "lib/score-import/import-types/ir/fervidex/parser"; import { Playtypes, integer } from "tachi-common"; import CreateLogCtx from "lib/logger/logger"; -import { SYMBOL_TachiAPIAuth } from "lib/constants/tachi"; +import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "lib/constants/tachi"; import { RequirePermissions } from "server/middleware/auth"; +import db from "external/mongo/db"; const logger = CreateLogCtx(__filename); @@ -68,7 +69,14 @@ const ValidateFervidexHeader: RequestHandler = (req, res, next) => { return next(); }; -const RequireInf2ModelHeader: RequestHandler = (req, res, next) => { +const RequireInf2ModelHeaderOrForceStatic: RequestHandler = async (req, res, next) => { + const settings = await db["fer-settings"].findOne({ userID: req[SYMBOL_TachiAPIAuth].userID! }); + + if (settings && settings.forceStaticImport) { + logger.debug(`User ${settings.userID} had forceStaticImport set, allowing request.`); + return next(); + } + const swModel = req.header("X-Software-Model"); if (!swModel) { @@ -150,7 +158,39 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => { return next(); }; -router.use(RequirePermissions("submit_score"), ValidateFervidexHeader, ValidateModelHeader); +const ValidateCards: RequestHandler = async (req, res, next) => { + const userID = req[SYMBOL_TachiAPIAuth]!.userID!; + + const cardFilters = await db["fer-settings"].findOne({ userID }); + + if (!cardFilters || !cardFilters.cards) { + return next(); + } + + const cardID = req.header("X-Account-Id"); + if (!cardID) { + return res.status(400).json({ + success: false, + description: `Fervidex did not provide a card ID.`, + }); + } + + if (!cardFilters.cards.includes(cardID)) { + return res.status(400).json({ + success: false, + description: `The card ID ${cardID} is not in your list of filters. Ignoring.`, + }); + } + + return next(); +}; + +router.use( + RequirePermissions("submit_score"), + ValidateFervidexHeader, + ValidateModelHeader, + ValidateCards +); /** * Submits all of a users data to Kamaitachi. This data is extremely minimal, @@ -160,7 +200,7 @@ router.use(RequirePermissions("submit_score"), ValidateFervidexHeader, ValidateM * * @name POST /ir/fervidex/profile/submit */ -router.post("/profile/submit", RequireInf2ModelHeader, async (req, res) => { +router.post("/profile/submit", RequireInf2ModelHeaderOrForceStatic, async (req, res) => { const userDoc = await GetUserWithIDGuaranteed(req[SYMBOL_TachiAPIAuth].userID!); const headers = { diff --git a/server/src/test-utils/mock-db/fer-settings.json b/server/src/test-utils/mock-db/fer-settings.json new file mode 100644 index 000000000..6afc986a5 --- /dev/null +++ b/server/src/test-utils/mock-db/fer-settings.json @@ -0,0 +1,7 @@ +[ + { + "userID": 1, + "cards": null, + "forceStaticImport": false + } +] \ No newline at end of file