Merge pull request #334 from TeamNewGuys:zkldi/issue-172-Import-charts-in-the-the-DB-if-not-present-for-USC

Zkldi/issue-172-Import-charts-in-the-the-DB-if-not-present-for-USC
This commit is contained in:
zkldi
2021-09-06 17:05:07 +01:00
committed by GitHub
19 changed files with 473 additions and 89 deletions
+1 -1
View File
@@ -73,7 +73,7 @@
"redis": "3.1.2",
"rimraf": "3.0.2",
"safe-json-stringify": "1.2.0",
"tachi-common": "0.2.1",
"tachi-common": "0.2.3",
"typescript": "4.3.4",
"winston": "3.3.3"
},
+4 -4
View File
@@ -54,7 +54,7 @@ specifiers:
rimraf: 3.0.2
safe-json-stringify: 1.2.0
supertest: 6.1.3
tachi-common: 0.2.1
tachi-common: 0.2.3
tap: 15.0.9
ts-node: 10.0.0
tsconfig-paths: 3.10.1
@@ -88,7 +88,7 @@ dependencies:
redis: 3.1.2
rimraf: 3.0.2
safe-json-stringify: 1.2.0
tachi-common: 0.2.1_ts-node@10.0.0+typescript@4.3.4
tachi-common: 0.2.3_ts-node@10.0.0+typescript@4.3.4
typescript: 4.3.4
winston: 3.3.3
@@ -3681,8 +3681,8 @@ packages:
strip-ansi: 6.0.0
dev: true
/tachi-common/0.2.1_ts-node@10.0.0+typescript@4.3.4:
resolution: {integrity: sha512-4Dc9K2o9s9ZDKiETLoegKM+rNC/D7BCTxdKeZnHJX8hcWo4lcTcKZbH51NHLb+hfDTGFYTgfHvm3nXSEcrQm9A==}
/tachi-common/0.2.3_ts-node@10.0.0+typescript@4.3.4:
resolution: {integrity: sha512-l1w5IQfaZejgl/+opB2K5mRkeQstreOq4+6+9A1kkGNVFu0lxvLYZdqSlJwbqYVAPOO7ssYZsi+FSUf81zowpg==}
dependencies:
monk: 7.3.4
tap: 15.0.9_ts-node@10.0.0+typescript@4.3.4
-22
View File
@@ -1,22 +0,0 @@
import db from "../db/db";
import CreateLogCtx from "../common/logger";
const logger = CreateLogCtx(__filename);
(async () => {
const users = await db.counters.findOne({
counterName: "users",
});
if (users) {
throw new Error(`"users" document already exists, exiting.`);
}
await db.counters.insert({
counterName: "users",
value: 1,
});
logger.info("Successfully initialised counter documents. Exiting.");
process.exit(0);
})();
+21
View File
@@ -0,0 +1,21 @@
import { CounterDocument } from "tachi-common";
import db from "external/mongo/db";
(async () => {
const uscSongCount = await db.songs.usc.find({});
const Counters: CounterDocument[] = [
{
counterName: "users",
value: 2,
},
{
counterName: "usc-song-id",
value: uscSongCount.length + 1,
},
];
db.counters.insert(Counters).then(() => {
process.exit(0);
});
})();
-13
View File
@@ -1,13 +0,0 @@
import { CounterDocument } from "tachi-common";
import db from "../../src/external/mongo/db";
const Counters: CounterDocument[] = [
{
counterName: "users",
value: 1,
},
];
db.counters.insert(Counters).then(() => {
process.exit(0);
});
+3
View File
@@ -34,6 +34,7 @@ import {
OAuth2ApplicationDocument,
integer,
FervidexSettingsDocument,
USCOrphanChart,
} from "tachi-common";
import monk, { TMiddleware } from "monk";
import CreateLogCtx from "lib/logger/logger";
@@ -177,6 +178,7 @@ const db = {
// 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"),
"usc-orphan-chart-queue": monkDB.get<USCOrphanChart>("usc-orphan-chart-queue"),
};
export type StaticDatabases =
@@ -208,6 +210,7 @@ export type StaticDatabases =
| "oauth2-clients"
| "oauth2-auth-codes"
| "fer-settings"
| "usc-orphan-chart-queue"
| "user-settings";
export type Databases = StaticDatabases | `songs-${Game}` | `charts-${Game}`;
+1 -1
View File
@@ -5,7 +5,7 @@ import { ServerConfig } from "lib/setup/config";
export type KtLogger = Logger & { severe: LeveledLogMethod };
const level = ServerConfig.LOG_LEVEL;
const level = process.env.LOG_LEVEL ?? ServerConfig.LOG_LEVEL;
const IN_TESTING = process.env.NODE_ENV === "test";
@@ -76,7 +76,12 @@ t.test("#DeriveNoteMod", (t) => {
});
const dm = (p: Partial<USCClientScore>) =>
ConverterIRUSC(d(uscScore, p), { chart: uscChart }, "ir/usc", logger);
ConverterIRUSC(
d(uscScore, p),
{ chartHash: uscChart.data.hashSHA1 as string },
"ir/usc",
logger
);
t.test("#ConverterIRUSC", (t) => {
t.beforeEach(ResetDBState);
@@ -1,7 +1,11 @@
import { USCClientScore } from "server/router/ir/usc/types";
import { FindSongOnID } from "utils/queries/songs";
import { KtLogger } from "lib/logger/logger";
import { InternalFailure, InvalidScoreFailure } from "../../../framework/common/converter-failures";
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "../../../framework/common/converter-failures";
import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils";
import { IRUSCContext } from "./types";
import { Lamps } from "tachi-common";
@@ -14,6 +18,7 @@ import {
USC_DEFAULT_PERFECT,
USC_DEFAULT_SLAM,
} from "lib/constants/usc-ir";
import db from "external/mongo/db";
/**
* Interprets the "note mod" used based on the USC score.
@@ -71,14 +76,22 @@ export const ConverterIRUSC: ConverterFunction<USCClientScore, IRUSCContext> = a
throw new InvalidScoreFailure(`Autoplay was enabled - Score is invalid.`);
}
const song = await FindSongOnID("usc", context.chart.songID);
const chartDoc = await db.charts.usc.findOne({
"data.hashSHA1": context.chartHash,
});
if (!song) {
logger.severe(`Song-Chart desync on USCIR ${context.chart.songID}.`);
throw new InternalFailure(`Song-Chart desync on USCIR ${context.chart.songID}.`);
if (!chartDoc) {
throw new KTDataNotFoundFailure("Chart is orphaned.", "ir/usc", data, context);
}
const { grade, percent } = GenericGetGradeAndPercent("usc", data.score, context.chart);
const song = await FindSongOnID("usc", chartDoc.songID);
if (!song) {
logger.severe(`Song-Chart desync on USCIR ${chartDoc.songID}.`);
throw new InternalFailure(`Song-Chart desync on USCIR ${chartDoc.songID}.`);
}
const { grade, percent } = GenericGetGradeAndPercent("usc", data.score, chartDoc);
const dryScore: DryScore<"usc:Single"> = {
comment: null,
@@ -106,5 +119,5 @@ export const ConverterIRUSC: ConverterFunction<USCClientScore, IRUSCContext> = a
},
};
return { chart: context.chart, song, dryScore };
return { chart: chartDoc, song, dryScore };
};
@@ -1,5 +1,4 @@
import t from "tap";
import { uscChart, uscScore } from "test-utils/test-data";
import CreateLogCtx from "lib/logger/logger";
import { ParseIRUSC } from "./parser";
@@ -10,14 +9,14 @@ t.test("#ParseIRUSC", (t) => {
t.test("Should validate and convert a score into an iterable", (t) => {
const res = ParseIRUSC(
{ score: uscScore } as unknown as Record<string, unknown>,
uscChart,
uscChart.data.hashSHA1 as string,
logger
);
t.hasStrict(res, {
game: "usc",
context: {
chart: uscChart,
chartHash: uscChart.data.hashSHA1,
},
iterable: [uscScore],
});
@@ -26,7 +25,7 @@ t.test("#ParseIRUSC", (t) => {
});
t.test("Should reject empty bodies", (t) => {
t.throws(() => ParseIRUSC({}, uscChart, logger), {
t.throws(() => ParseIRUSC({}, uscChart.data.hashSHA1 as string, logger), {
statusCode: 400,
message: /invalid usc score/iu,
});
@@ -24,7 +24,7 @@ const PR_USCIRScore: PrudenceSchema = {
export function ParseIRUSC(
body: Record<string, unknown>,
chart: ChartDocument<"usc:Single">,
chartHash: string,
logger: KtLogger
): ParserFunctionReturns<USCClientScore, IRUSCContext> {
const err = p(
@@ -40,7 +40,7 @@ export function ParseIRUSC(
return {
context: {
chart,
chartHash,
},
game: "usc",
iterable: [body.score] as USCClientScore[],
@@ -1,5 +1,3 @@
import { ChartDocument } from "tachi-common";
export interface IRUSCContext {
chart: ChartDocument<"usc:Single">;
chartHash: string;
}
+3
View File
@@ -72,6 +72,7 @@ export interface TachiConfig {
FROM: string;
SENDMAIL_BIN?: string;
};
USC_QUEUE_SIZE: integer;
}
const isValidOauth2 = p.optional({
@@ -107,6 +108,7 @@ const err = p(config, {
FROM: "string",
SENDMAIL_BIN: "*string",
}),
USC_QUEUE_SIZE: p.optional(p.gteInt(2)),
});
if (err) {
@@ -126,6 +128,7 @@ const tachiConfig = config as TachiConfig;
// default rate limit 500
tachiConfig.RATE_LIMIT ??= 500;
tachiConfig.OAUTH_CLIENT_CAP ??= 15;
tachiConfig.USC_QUEUE_SIZE ??= 3;
if (tachiConfig.EMAIL_CONFIG) {
tachiConfig.EMAIL_CONFIG.SENDMAIL_BIN ??= "/usr/bin/sendmail";
@@ -0,0 +1,118 @@
import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import { ServerConfig } from "lib/setup/config";
import { ChartDocument, integer, SongDocument } from "tachi-common";
import { GetNextCounterValue } from "utils/db";
import { Random20Hex } from "utils/misc";
import { USCClientChart } from "./types";
const logger = CreateLogCtx(__filename);
/**
* Handles the orphan queue for USC charts. If the provided chart was just
* unorphaned, returns the chart. Else, returns null.
*/
export async function HandleOrphanQueue(uscChartDoc: USCClientChart, userID: integer) {
const chartName = `${uscChartDoc.artist} - ${uscChartDoc.title} (${IndexToDiff(
uscChartDoc.difficulty
)})`;
logger.debug(`Recieved orphanqueue request for ${chartName}.`);
const orphanChart = await db["usc-orphan-chart-queue"].findOne({
"chartDoc.data.hashSHA1": uscChartDoc.chartHash,
});
if (!orphanChart) {
logger.verbose(`Recieved unknown chart ${chartName}, orphaning.`);
const { song, chart } = ConvertUSCChart(uscChartDoc);
await db["usc-orphan-chart-queue"].insert({
chartDoc: chart,
songDoc: song,
userIDs: [userID],
});
return null;
}
orphanChart.userIDs.push(userID);
const uniqueUsersArr = [...new Set(orphanChart.userIDs)];
const playcount = uniqueUsersArr.length;
// If N or more people have played this chart while orphaned, unorphan
// it.
if (playcount >= ServerConfig.USC_QUEUE_SIZE) {
logger.info(
`Song ${chartName} was unorphaned by ${uniqueUsersArr.join(", ")} and ${userID}.`
);
const songID = await GetNextCounterValue("usc-song-id");
logger.verbose(`${chartName} has been assigned songID ${songID}.`);
const { songDoc, chartDoc } = orphanChart;
songDoc.id = songID;
chartDoc.songID = songID;
await db.songs.usc.insert(songDoc);
await db.charts.usc.insert(chartDoc);
await db["usc-orphan-chart-queue"].remove({
"chartDoc.data.hashSHA1": chartDoc.data.hashSHA1,
});
return chartDoc;
}
// otherwise, update the state of this orphan.
logger.verbose(`UserID ${userID} played ${chartName}, which is now at ${playcount} plays.`);
await db["usc-orphan-chart-queue"].update(
{
"chartDoc.data.hashSHA1": uscChartDoc.chartHash,
},
{
$set: {
userIDs: uniqueUsersArr,
},
}
);
return null;
}
function ConvertUSCChart(uscChartDoc: USCClientChart) {
const chart: ChartDocument<"usc:Single"> = {
chartID: Random20Hex(),
difficulty: IndexToDiff(uscChartDoc.difficulty),
isPrimary: true,
level: "?",
levelNum: 0,
playtype: "Single",
rgcID: null,
songID: 0,
versions: [],
data: {
hashSHA1: uscChartDoc.chartHash,
isOfficial: false,
},
};
const song: SongDocument<"usc"> = {
title: uscChartDoc.title,
artist: uscChartDoc.artist,
firstVersion: null,
id: 0,
data: {},
"alt-titles": [],
"search-titles": [],
};
return { chart, song };
}
function IndexToDiff(index: 0 | 1 | 2 | 3): ChartDocument<"usc:Single">["difficulty"] {
return (["NOV", "ADV", "EXH", "INF"] as const)[index];
}
@@ -457,3 +457,229 @@ t.test("POST /replays", (t) => {
t.end();
});
t.test("POST /scores", async (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(async () => {
await db["api-tokens"].insert({
userID: 1,
identifier: "token",
permissions: { submit_score: true },
token: "token",
});
});
const validRequest = {
chart: {
chartHash: "USC_CHART_HASH",
artist: "test_artist",
title: "test",
level: 5,
difficulty: 0,
effector: "test",
illustrator: "test",
bpm: "test",
},
score: {
score: 9_000_000,
gauge: 50,
timestamp: 1_000_000,
crit: 5,
near: 4,
error: 3,
options: {
gaugeType: 0,
gaugeOpt: 0,
mirror: true,
random: false,
autoFlags: 0,
},
windows: {
perfect: 46,
good: 150,
hold: 150,
miss: 300,
slam: 84,
},
},
};
t.test("Should submit a score from a valid request.", async (t) => {
const res = await mockApi
.post("/ir/usc/scores")
.set("Authorization", "Bearer token")
.send(validRequest);
t.equal(res.body.statusCode, 20);
const dbScore = await db.scores.findOne({
game: "usc",
});
t.hasStrict(dbScore, {
scoreData: {
score: 9_000_000,
percent: 90,
lamp: "FAILED",
},
scoreMeta: {
noteMod: "MIRROR",
gaugeMod: "NORMAL",
},
});
t.end();
});
t.test("Should orphan a score and return 22 if chart has never been seen.", async (t) => {
const res = await mockApi
.post("/ir/usc/scores")
.set("Authorization", "Bearer token")
.send(
deepmerge(validRequest, {
chart: {
chartHash: "NEW_CHART",
},
})
);
t.equal(res.body.statusCode, 22);
const dbScore = await db.scores.findOne({
game: "usc",
});
t.equal(dbScore, null, "Should not exist in db.");
const orphanData = await db["usc-orphan-chart-queue"].findOne({
"chartDoc.data.hashSHA1": "NEW_CHART",
});
t.strictSame(orphanData?.userIDs, [1]);
t.strictSame(orphanData?.songDoc, {
title: "test",
artist: "test_artist",
firstVersion: null,
id: 0,
"alt-titles": [],
"search-titles": [],
data: {},
});
t.hasStrict(orphanData?.chartDoc, {
songID: 0,
difficulty: "NOV",
data: {
hashSHA1: "NEW_CHART",
},
isPrimary: true,
level: "?",
levelNum: 0,
});
t.end();
});
t.test("Should unorphan charts on their Nth unique user.", async (t) => {
await db["api-tokens"].insert([
{
userID: 2,
identifier: "token2",
permissions: { submit_score: true },
token: "token2",
},
{
userID: 3,
identifier: "token3",
permissions: { submit_score: true },
token: "token3",
},
]);
await db.users.insert([
{
id: 2,
username: "foo",
usernameLowercase: "foo",
},
{
id: 3,
username: "bar",
usernameLowercase: "bar",
},
] as PublicUserDocument[]);
const res = await mockApi
.post("/ir/usc/scores")
.set("Authorization", "Bearer token")
.send(
deepmerge(validRequest, {
chart: {
chartHash: "NEW_CHART",
},
})
);
t.equal(res.body.statusCode, 22);
const res2 = await mockApi
.post("/ir/usc/scores")
.set("Authorization", "Bearer token2")
.send(
deepmerge(validRequest, {
chart: {
chartHash: "NEW_CHART",
},
})
);
t.equal(res2.body.statusCode, 22);
const orphanData = await db["usc-orphan-chart-queue"].findOne({
"chartDoc.data.hashSHA1": "NEW_CHART",
});
t.strictSame(orphanData?.userIDs, [1, 2]);
const res3 = await mockApi
.post("/ir/usc/scores")
.set("Authorization", "Bearer token3")
.send(
deepmerge(validRequest, {
chart: {
chartHash: "NEW_CHART",
},
})
);
t.equal(res3.body.statusCode, 20);
const orphanData2 = await db["usc-orphan-chart-queue"].findOne({
"chartDoc.data.hashSHA1": "NEW_CHART",
});
t.equal(orphanData2, null, "Orphan data should be removed from the database.");
const score = await db.scores.findOne({
game: "usc",
userID: 3,
});
t.hasStrict(score, {
scoreData: {
score: 9_000_000,
percent: 90,
lamp: "FAILED",
},
scoreMeta: {
noteMod: "MIRROR",
gaugeMod: "NORMAL",
},
});
t.end();
});
t.end();
});
+58 -27
View File
@@ -1,4 +1,5 @@
import { Router, RequestHandler } from "express";
import p from "prudence";
import { FindChartOnSHA256 } from "utils/queries/charts";
import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "lib/constants/tachi";
import db from "external/mongo/db";
@@ -21,20 +22,24 @@ import { CDNStore } from "lib/cdn/cdn";
import { ONE_MEGABYTE } from "lib/constants/filesize";
import { RequirePermissions } from "server/middleware/auth";
import { GetUSCIRReplayURL } from "lib/cdn/url-format";
import { FormatPrError } from "utils/prudence";
import { USCClientChart } from "./types";
import { HandleOrphanQueue } from "./orphan-queue";
const logger = CreateLogCtx(__filename);
const router: Router = Router({ mergeParams: true });
const STATUS_CODES = {
UNAUTH: 41,
CHART_REFUSE: 42,
FORBIDDEN: 43,
NOT_FOUND: 44,
SERVER_ERROR: 50,
SUCCESS: 20,
BAD_REQ: 40,
};
enum STATUS_CODES {
UNAUTH = 41,
CHART_REFUSE = 42,
FORBIDDEN = 43,
NOT_FOUND = 44,
SERVER_ERROR = 50,
SUCCESS = 20,
ACCEPTED = 22,
BAD_REQ = 40,
}
const ValidateUSCRequest: RequestHandler = async (req, res, next) => {
const token = req.header("Authorization");
@@ -95,7 +100,7 @@ router.get("/", (req, res) =>
);
const RetrieveChart: RequestHandler = async (req, res, next) => {
const chart = await FindChartOnSHA256("usc", req.params.chartHash);
const chart = await db.charts.usc.findOne({ "data.hashSHA1": req.params.chartHash });
AssignToReqTachiData(req, {
uscChartDoc: (chart ?? undefined) as ChartDocument<"usc:Single"> | undefined,
@@ -230,36 +235,52 @@ router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) =>
});
});
const PR_USCIRChartDoc = {
chartHash: "string",
artist: "string",
title: "string",
level: p.isBoundedInteger(1, 20),
difficulty: p.isBoundedInteger(0, 3),
effector: "string",
illustrator: "string",
bpm: "string",
};
/**
* Sends a score to the server.
* https://uscir.readthedocs.io/en/latest/endpoints/score-submit.html
* @name POST /ir/usc/scores
*/
router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
if (typeof req.body.chart !== "object" || req.body.chart === null) {
const chartErr = p(
req.body.chart,
PR_USCIRChartDoc,
{},
{
throwOnNonObject: false,
allowExcessKeys: true,
}
);
if (chartErr) {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: "Invalid chart provided.",
description: FormatPrError(chartErr, "Invalid chart."),
});
}
if (typeof req.body.chart.chartHash !== "string") {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: "Invalid chart provided.",
});
}
const uscChart = req.body.chart as USCClientChart;
const chartDoc = (await FindChartOnSHA256(
"usc",
req.body.chart.chartHash
)) as ChartDocument<"usc:Single"> | null;
let chartDoc = (await db.charts.usc.findOne({
"data.hashSHA1": uscChart.chartHash,
})) as ChartDocument<"usc:Single"> | null;
// If the chart doesn't exist, call HandleOrphanQueue.
// If this chart has never been seen before, orphan it.
// If this chart is already orphaned, increase its unique player
// playcount.
if (!chartDoc) {
return res.status(200).json({
statusCode: STATUS_CODES.CHART_REFUSE,
description: "This chart is not supported.",
});
chartDoc = await HandleOrphanQueue(uscChart, req[SYMBOL_TachiAPIAuth].userID!);
}
const userDoc = await GetUserWithID(req[SYMBOL_TachiAPIAuth]!.userID!);
@@ -272,10 +293,20 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
});
}
const importParser = (logger: KtLogger) => ParseIRUSC(req.body, chartDoc, logger);
const importParser = (logger: KtLogger) => ParseIRUSC(req.body, uscChart.chartHash, logger);
const importRes = await ExpressWrappedScoreImportMain(userDoc, false, "ir/usc", importParser);
// If this was an orphan chart request, return ACCEPTED,
// since it may be unorphaned in the future
if (!chartDoc) {
return res.status(200).json({
statusCode: STATUS_CODES.ACCEPTED,
description:
"This score has been accepted, but is waiting for more players before its parent chart is accepted.",
});
}
if (importRes.statusCode === 500) {
return res.status(200).json({
statusCode: STATUS_CODES.SERVER_ERROR,
@@ -11,7 +11,7 @@
"CUSTOM": true
},
"data": {
"hashSHA256": "USC_CHART_HASH"
"hashSHA1": "USC_CHART_HASH"
},
"isPrimary": true,
"versions": []
@@ -6,5 +6,9 @@
{
"counterName": "users",
"value": 2
},
{
"counterName": "usc-song-id",
"value": 2
}
]
+1 -3
View File
@@ -6,8 +6,6 @@
"firstVersion": null,
"alt-titles": [],
"search-titles": [],
"data": {
"sdvxEquiv": null
}
"data": {}
}
]