Merge pull request #322 from zkldi:zkldi/issue-89-Fervidex-Card-Management

Support fervidex card filtering
This commit is contained in:
zkldi
2021-09-04 04:06:13 +01:00
committed by GitHub
11 changed files with 364 additions and 11 deletions
+1 -1
View File
@@ -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"
},
+4 -4
View File
@@ -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
+3
View File
@@ -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<FervidexSettingsDocument>("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}`;
+1
View File
@@ -91,6 +91,7 @@ const staticIndexes: Partial<Record<Databases, Index[]>> = {
],
"user-settings": [index({ userID: 1 }, UNIQUE)],
"user-private-information": [index({ userID: 1 }, UNIQUE)],
"fer-settings": [index({ userID: 1 }, UNIQUE)],
};
const indexes: Partial<Record<Databases, Index[]>> = staticIndexes;
@@ -87,8 +87,8 @@ router.post(
});
}
const clientID = Random20Hex();
const clientSecret = Random20Hex();
const clientID = `CI${Random20Hex()}`;
const clientSecret = `CS${Random20Hex()}`;
const clientDoc = {
clientID,
@@ -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();
});
@@ -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;
@@ -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;
@@ -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();
});
+44 -4
View File
@@ -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 = {
@@ -0,0 +1,7 @@
[
{
"userID": 1,
"cards": null,
"forceStaticImport": false
}
]