Groundwork for USC IR

This commit is contained in:
zkldi
2021-05-21 11:42:46 +01:00
parent 0edb306cc1
commit 1bf8441d07
14 changed files with 458 additions and 91 deletions
+4 -1
View File
@@ -1,5 +1,6 @@
import { Session, SessionData } from "express-session";
import { KtchiSessionData } from "../../src/types";
import { SYMBOL_KtchiData } from "../../src/constants/ktchi";
import { KtchiRequestData, KtchiSessionData } from "../../src/types";
export {};
@@ -15,6 +16,8 @@ declare global {
namespace Express {
interface Request {
session: Session & Partial<SessionData>;
[SYMBOL_KtchiData]?: Partial<KtchiRequestData>;
}
}
}
+3 -3
View File
@@ -62,7 +62,7 @@ dependencies:
express-session: 1.17.1
fast-json-stable-hash: 1.0.2
fast-xml-parser: 3.19.0
kamaitachi-common: github.com/zkldi/Kamaitachi-common/d93f07f61f44116f709365af61606566765e297e
kamaitachi-common: github.com/zkldi/Kamaitachi-common/4f1a7215bfc4fc7ab140f94ae1a8f10dd69129b6
mongodb: 3.6.6
monk: 7.3.4
multer: 1.4.2
@@ -4644,8 +4644,8 @@ packages:
'@types/yoga-layout': 1.9.2
dev: true
github.com/zkldi/Kamaitachi-common/d93f07f61f44116f709365af61606566765e297e:
resolution: {tarball: https://codeload.github.com/zkldi/Kamaitachi-common/tar.gz/d93f07f61f44116f709365af61606566765e297e}
github.com/zkldi/Kamaitachi-common/4f1a7215bfc4fc7ab140f94ae1a8f10dd69129b6:
resolution: {tarball: https://codeload.github.com/zkldi/Kamaitachi-common/tar.gz/4f1a7215bfc4fc7ab140f94ae1a8f10dd69129b6}
name: kamaitachi-common
version: 0.1.0
dependencies:
+5 -3
View File
@@ -25,12 +25,13 @@ const router: Router = Router({ mergeParams: true });
const LAZY_EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u;
/* istanbul ignore next */
const BASE_DOMAIN = process.env.NODE_ENV === "production" ? ".kamaitachi.xyz" : "127.0.0.1";
const SHOULD_COOKIES_SECURE = process.env.NODE_ENV === "production";
/**
* Logs in a user.
* @name /api/auth/login
* @name POST /api/auth/login
*/
router.post(
"/login",
@@ -56,6 +57,7 @@ router.post(
logger.verbose(`Recieved login request with username ${req.body.username} (${req.ip})`);
/* istanbul ignore next */
if (process.env.NODE_ENV === "production") {
logger.verbose("Validating captcha...");
const validCaptcha = await ValidateCaptcha(
@@ -152,7 +154,7 @@ router.post(
/**
* Registers a new user.
* @name /api/auth/register
* @name POST /api/auth/register
*/
router.post(
"/register",
@@ -268,7 +270,7 @@ router.post(
/**
* Logs out the requesting user.
* @name /api/auth/logout
* @name POST /api/auth/logout
*/
router.post("/logout", RequireLoggedIn, (req, res) => {
req.session.destroy(() => 0);
+1 -1
View File
@@ -42,7 +42,7 @@ const parseMultipartScoredata = (req: Request, res: Response, next: NextFunction
/**
* Import scores from a file. Expects the post request to be multipart, and to provide a scoreData file.
* @name /api/import/file
* @name POST /api/import/file
*/
router.post(
"/file",
+68 -82
View File
@@ -119,6 +119,10 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => {
});
}
if (softID.model === MODEL_INFINITAS_2) {
return next(); // allow anything for inf2.
}
if (softID.ext !== EXT_HEROIC_VERSE) {
logger.info(
`Rejected invalid Software Model ${softID.ext} from user ${
@@ -141,6 +145,8 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => {
return next();
};
router.use(RequireLoggedIn, ValidateFervidexHeader, ValidateModelHeader);
/**
* Submits all of a users data to Kamaitachi. This data is extremely minimal,
* as only a users Lamp and Score are sent. As such, this is not the prefered
@@ -149,29 +155,23 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => {
*
* @name /api/ir/fervidex/profile/submit
*/
router.post(
"/profile/submit",
RequireLoggedIn,
ValidateFervidexHeader,
RequireInf2ModelHeader,
async (req, res) => {
const userDoc = await GetUserWithIDGuaranteed(req.session.ktchi!.userID);
router.post("/profile/submit", RequireInf2ModelHeader, async (req, res) => {
const userDoc = await GetUserWithIDGuaranteed(req.session.ktchi!.userID);
const headers = {
// guaranteed to exist because of RequireInf2ModelHeader
model: req.header("X-Software-Model")!,
};
const headers = {
// guaranteed to exist because of RequireInf2ModelHeader
model: req.header("X-Software-Model")!,
};
const responseData = await ExpressWrappedScoreImportMain(
userDoc,
false,
"ir/fervidex-static",
(logger) => ParseFervidexStatic(req.body, headers, logger)
);
const responseData = await ExpressWrappedScoreImportMain(
userDoc,
false,
"ir/fervidex-static",
(logger) => ParseFervidexStatic(req.body, headers, logger)
);
return res.status(responseData.statusCode).json(responseData.body);
}
);
return res.status(responseData.statusCode).json(responseData.body);
});
/**
* Submits a single score to Kamaitachi. In contrast to profile/submit, this
@@ -180,37 +180,31 @@ router.post(
*
* @name /api/ir/fervidex/score/submit
*/
router.post(
"/score/submit",
RequireLoggedIn,
ValidateFervidexHeader,
ValidateModelHeader,
async (req, res) => {
const userDoc = await GetUserWithIDGuaranteed(req.session.ktchi!.userID);
router.post("/score/submit", ValidateModelHeader, async (req, res) => {
const userDoc = await GetUserWithIDGuaranteed(req.session.ktchi!.userID);
const model = req.header("X-Software-Model");
const model = req.header("X-Software-Model");
if (!model) {
return res.status(400).json({
success: false,
description: "No X-Software-Model header provided?",
});
}
const headers = {
model,
};
const responseData = await ExpressWrappedScoreImportMain(
userDoc,
true,
"ir/fervidex",
(logger) => ParseFervidexSingle(req.body, headers, logger)
);
return res.status(responseData.statusCode).json(responseData.body);
if (!model) {
return res.status(400).json({
success: false,
description: "No X-Software-Model header provided?",
});
}
);
const headers = {
model,
};
const responseData = await ExpressWrappedScoreImportMain(
userDoc,
true,
"ir/fervidex",
(logger) => ParseFervidexSingle(req.body, headers, logger)
);
return res.status(responseData.statusCode).json(responseData.body);
});
/**
* Submits the result of a class to Kamaitachi. This contains the dan played
@@ -218,43 +212,35 @@ router.post(
*
* @name /api/ir/fervidex/class/submit
*/
router.post(
"/class/submit",
RequireLoggedIn,
ValidateFervidexHeader,
ValidateModelHeader,
async (req, res) => {
if (!req.body.cleared) {
return res
.status(200)
.json({ success: true, description: "No Update Made.", body: {} });
}
router.post("/class/submit", ValidateModelHeader, async (req, res) => {
if (!req.body.cleared) {
return res.status(200).json({ success: true, description: "No Update Made.", body: {} });
}
const classVal = FERVIDEX_COURSE_LOOKUP[req.body.course_id];
const classVal = FERVIDEX_COURSE_LOOKUP[req.body.course_id];
if (!classVal) {
return res.status(400).json({
success: false,
description: `Invalid courseID of ${req.body.course_id}.`,
});
}
// is 0 or 1.
const playtype: Playtypes["iidx"] = req.body.playstyle ? "SP" : "DP";
const r = await UpdateClassIfGreater(
req.session.ktchi!.userID,
"iidx",
playtype,
"dan",
classVal
);
return res.status(200).json({
success: true,
description: r === false ? "Dan unchanged." : "Dan changed!",
if (!classVal) {
return res.status(400).json({
success: false,
description: `Invalid courseID of ${req.body.course_id}.`,
});
}
);
// is 0 or 1.
const playtype: Playtypes["iidx"] = req.body.playstyle ? "SP" : "DP";
const r = await UpdateClassIfGreater(
req.session.ktchi!.userID,
"iidx",
playtype,
"dan",
classVal
);
return res.status(200).json({
success: true,
description: r === false ? "Dan unchanged." : "Dan changed!",
});
});
export default router;
+2
View File
@@ -3,6 +3,7 @@ import barbatosIR from "./barbatos/barbatos";
import chunitachiIR from "./chunitachi/chunitachi";
import directManualIR from "./direct-manual/direct-manual";
import fervidexIR from "./fervidex/fervidex";
import uscIR from "./usc/usc";
const router: Router = Router({ mergeParams: true });
@@ -10,5 +11,6 @@ router.use("/barbatos", barbatosIR);
router.use("/chunitachi", chunitachiIR);
router.use("/direct-manual", directManualIR);
router.use("/fervidex", fervidexIR);
router.use("/usc", uscIR);
export default router;
+122
View File
@@ -0,0 +1,122 @@
import { integer, PBScoreDocument, ScoreDocument } from "kamaitachi-common";
import CreateLogCtx from "../../../common/logger";
import db from "../../../db/db";
const logger = CreateLogCtx("ir/usc/common.ts");
export interface USCServerScore {
score: integer;
lamp: 0 | 1 | 2 | 3 | 4 | 5;
timestamp: integer;
crit: integer;
near: integer;
error: integer;
ranking: integer;
gaugeMod: "NORMAL" | "HARD";
noteMod: "NORMAL" | "MIRROR" | "RANDOM" | "MIR-RAN";
username: string;
}
export interface USCClientScore {
score: integer;
gameflags: integer;
gauge: number;
timestamp: integer;
crit: integer;
near: integer;
error: integer;
options: {
gaugeType: integer;
gaugeOpt: integer;
mirror: boolean;
random: boolean;
autoFlags: integer; //???
};
windows: {
perfect: number;
good: number;
hold: number;
miss: number;
slam: number;
};
}
export interface USCClientChart {
chartHash: string;
artist: string;
title: string;
level: integer;
difficulty: 0 | 1 | 2 | 3;
effector: string;
illustrator: string;
bpm: string;
}
export const KT_LAMP_TO_USC: Record<
PBScoreDocument<"usc:Single">["scoreData"]["lamp"],
USCServerScore["lamp"]
> = {
// we don't do NO PLAY, so its not handled.
FAILED: 1,
CLEAR: 2,
"EXCESSIVE CLEAR": 3,
"ULTIMATE CHAIN": 4,
"PERFECT ULTIMATE CHAIN": 5,
};
/**
* Converts a Kamaitachi Score to the ServerScoreDocument
* as specified in the USCIR spec. This function silently
* returns sentinel values in the case that certain
* fields are null.
*/
export async function KtchiScoreToServerScore(
ktchiScore: PBScoreDocument<"usc:Single">
): Promise<USCServerScore> {
const userDoc = await db.users.findOne(
{
id: ktchiScore.userID,
},
{
projection: {
username: 1,
},
}
);
if (!userDoc) {
logger.severe(
`User ${ktchiScore.userID} from PB on chart ${ktchiScore.chartID} has no user document?`
);
throw new Error(
`User ${ktchiScore.userID} from PB on chart ${ktchiScore.chartID} has no user document?`
);
}
const scorePB = (await db.scores.findOne({
scoreID: ktchiScore.composedFrom.scorePB,
})) as ScoreDocument<"usc:Single"> | null;
if (!scorePB) {
logger.severe(
`Score ${ktchiScore.composedFrom.scorePB} does not exist, but is referenced in ${ktchiScore.userID}'s PBDoc on ${ktchiScore.chartID}?`
);
throw new Error(
`User ${ktchiScore.userID} from PB on chart ${ktchiScore.chartID} has no user document?`
);
}
return {
score: ktchiScore.scoreData.score,
timestamp: Math.floor((ktchiScore.timeAchieved ?? 0) / 1000),
crit: ktchiScore.scoreData.hitData.critical ?? 0,
near: ktchiScore.scoreData.hitData.near ?? 0,
error: ktchiScore.scoreData.hitData.miss ?? 0,
ranking: ktchiScore.rankingData.rank,
lamp: KT_LAMP_TO_USC[ktchiScore.scoreData.lamp],
username: userDoc.username,
noteMod: scorePB.scoreMeta.noteMod ?? "NORMAL",
gaugeMod: scorePB.scoreMeta.gaugeMod ?? "NORMAL",
};
}
+230
View File
@@ -0,0 +1,230 @@
import { Router, RequestHandler } from "express";
import { FindChartOnSHA256 } from "../../../common/database-lookup/chart";
import { SYMBOL_KtchiData } from "../../../constants/ktchi";
import db from "../../../db/db";
import { ChartDocument, PBScoreDocument } from "kamaitachi-common";
import { AssertStrAsPositiveNonZeroInt } from "../../../score-import/framework/common/string-asserts";
import CreateLogCtx from "../../../common/logger";
import { KtchiScoreToServerScore } from "./common";
const logger = CreateLogCtx("usc.ts");
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,
};
// This is an implementation of the USCIR spec as per https://uscir.readthedocs.io.
// This specification always returns 200 OK, regardless of whether the result was okay
// as the HTTP code is used to determine whether the server received the request properly,
// rather than the result of the request.
const ValidateUSCRequest: RequestHandler = async (req, res, next) => {
const token = req.header("Authorization");
if (!token) {
return res.status(200).json({
statusCode: STATUS_CODES.UNAUTH,
description: "No auth token provided.",
});
}
const splitToken = token.split(" ");
if (splitToken.length !== 2 || splitToken[0] !== "Bearer") {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: "Invalid Authorization Header. Expected Bearer <token>",
});
}
const uscAuthDoc = await db["usc-auth-tokens"].findOne({
token: splitToken[1],
});
if (!uscAuthDoc) {
return res.status(200).json({
statusCode: STATUS_CODES.UNAUTH,
description: "Unauthorized.",
});
}
req[SYMBOL_KtchiData] = { uscAuthDoc };
};
router.use(ValidateUSCRequest);
/**
* Used to check your connection to the server, and receive some basic information.
* https://uscir.readthedocs.io/en/latest/endpoints/heartbeat.html
* @name GET /api/ir/usc
*/
router.get("/", (req, res) =>
res.status(200).json({
statusCode: STATUS_CODES.SUCCESS,
description: "IR Request Successful.",
body: {
serverTime: Math.floor(Date.now() / 1000),
serverName: "Kamaitachi BLACK",
irVersion: "0.3.0-a",
},
})
);
const RetrieveChart: RequestHandler = async (req, res, next) => {
const chart = await FindChartOnSHA256("usc", req.params.chartHash);
req[SYMBOL_KtchiData]!.uscChartDoc = (chart ?? undefined) as
| ChartDocument<"usc:Single">
| undefined;
return next();
};
const MAX_N = 10;
/**
* Used to check if the server will accept a score for a given chart in advance of submitting it.
* https://uscir.readthedocs.io/en/latest/endpoints/chart-charthash.html
* @name GET /api/ir/usc/charts/:chartHash
*/
router.get("/charts/:chartHash", RetrieveChart, (req, res) => {
const chart = req[SYMBOL_KtchiData]!.uscChartDoc;
if (!chart) {
return res.status(200).json({
statusCode: STATUS_CODES.CHART_REFUSE,
description: "This chart is not tracked, and will not be accepted.",
});
}
return res.status(200).json({
statusCode: STATUS_CODES.SUCCESS,
description: "This chart is tracked by the IR.",
});
});
/**
* Used to retrieve the current server record for the chart with the specified hash.
* https://uscir.readthedocs.io/en/latest/endpoints/record.html
* @name GET /api/ir/usc/charts/:chartHash/record
*/
router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => {
const chart = req[SYMBOL_KtchiData]!.uscChartDoc;
// spec ambigious here
if (!chart) {
return res.status(200).json({
statusCode: STATUS_CODES.CHART_REFUSE,
description: "This IR is not currently tracking this chart.",
});
}
const serverRecord = (await db["score-pbs"].findOne({
chartID: chart.chartID,
"rankingData.rank": 1,
})) as PBScoreDocument<"usc:Single"> | null;
if (!serverRecord) {
// @todo See uscir-spec #1
return res.status(200).json({
success: STATUS_CODES.SERVER_ERROR,
description: "TODO",
});
}
const serverScore = await KtchiScoreToServerScore(serverRecord);
return res.status(200).json({
success: STATUS_CODES.SUCCESS,
description: "Retrieved score.",
body: serverScore,
});
});
/**
* Used to retrieve some particular useful subset of the scores from the server.
* https://uscir.readthedocs.io/en/latest/endpoints/leaderboard.html
* @name GET /api/ir/usc/charts/:chartHash/leaderboard
*/
router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => {
const chart = req[SYMBOL_KtchiData]!.uscChartDoc!;
if (!(typeof req.query.mode === "string" && ["best", "rivals"].includes(req.query.mode))) {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: `Invalid 'mode' param - expected 'best' or 'rivals'.`,
});
}
if (typeof req.query.n !== "string") {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${MAX_N}.`,
});
}
let n;
try {
n = AssertStrAsPositiveNonZeroInt(req.query.n, "Invalid 'N' param.");
} catch (err) {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${MAX_N}.`,
});
}
if (n >= MAX_N) {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: `Invalid 'n' param - expected a positive non-zero integer less than or equal to ${MAX_N}.`,
});
}
const mode = req.query.mode as "best" | "rivals";
if (mode === "rivals") {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: "This is currently unsupported.",
});
}
const bestScores = (await db["score-pbs"].find(
{
chartID: chart.chartID,
},
{
sort: {
"scoreData.perecent": -1,
},
limit: n,
}
)) as PBScoreDocument<"usc:Single">[];
const serverScores = await Promise.all(bestScores.map(KtchiScoreToServerScore));
return res.status(200).json({
statusCode: STATUS_CODES.SUCCESS,
description: `Returned ${serverScores.length} scores.`,
body: serverScores,
});
});
/**
* Sends a score to the server.
* https://uscir.readthedocs.io/en/latest/endpoints/score-submit.html
* @name POST /api/ir/usc/scores
*/
router.post("/scores", async (req, res) => {});
export default router;
@@ -199,3 +199,12 @@ export function FindSDVXChartOnInGameIDVersion(
versions: version,
});
}
export function FindChartOnSHA256(game: Game, hash: string) {
// @todo #112 Reject requests to this for things that
// dont have sha256 props.
return db.charts[game].findOne({
"data.hashSHA256": hash,
});
}
+1
View File
@@ -0,0 +1 @@
export const SYMBOL_KtchiData = Symbol("ktchi-request-patch");
+2
View File
@@ -8,6 +8,7 @@ import {
TierlistParent,
InviteCodeDocument,
TierlistDataDocument,
USCAuthDocument,
MilestoneDocument,
NotificationDocument,
FolderChartLookup,
@@ -118,6 +119,7 @@ const db = {
"iidx-eam-scores": monkDB.get<IIDXEamusementScoreDocument>("iidx-eam-scores"),
"game-stats": monkDB.get<UserGameStats>("game-stats"),
"kai-auth-tokens": monkDB.get<KaiAuthDocument>("kai-auth-tokens"),
"usc-auth-tokens": monkDB.get<USCAuthDocument>("usc-auth-tokens"),
};
export default db;
+1 -1
View File
@@ -56,7 +56,7 @@ app.use("/api", apiRouter);
/**
* If any user gets to this point, we send them index.html and let react router do the routing (and 404ing)
* @name *
* @name ALL *
*/
app.get("*", (req, res) => res.status(200).send("todo"));
app.all("*", (req, res) =>
+10
View File
@@ -5,7 +5,9 @@ import {
integer,
ScoreDocument,
AnySongDocument,
USCAuthDocument,
MongoDBDocument,
ChartDocument,
Game,
Playtypes,
} from "kamaitachi-common";
@@ -185,3 +187,11 @@ export interface ImportTypeContextMap {
* Clarity type for empty objects - such as in context.
*/
export type EmptyObject = Record<string, never>;
/**
* Data that may be monkey-patched onto req.ktchi. This holds things such as middleware results.
*/
export interface KtchiRequestData {
uscAuthDoc?: USCAuthDocument;
uscChartDoc?: ChartDocument<"usc:Single">;
}