mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-10 00:08:22 +03:00
rename src/common to src/utils
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
AddNewUserAPIKey,
|
||||
CreateAPIKey,
|
||||
CreateInviteCode,
|
||||
AddNewInvite,
|
||||
ReinstateInvite,
|
||||
ValidateCaptcha,
|
||||
} from "./auth";
|
||||
import t from "tap";
|
||||
import db, { CloseMongoConnection } from "../external/mongo/db";
|
||||
import { PrivateUserDocument } from "kamaitachi-common";
|
||||
import { prAssert } from "../test-utils/asserts";
|
||||
import Prudence from "prudence";
|
||||
import ResetDBState from "../test-utils/reset-db-state";
|
||||
import { MockFetch } from "../test-utils/mock-fetch";
|
||||
|
||||
t.test("#CreateAPIKey", (t) => {
|
||||
t.match(
|
||||
CreateAPIKey(),
|
||||
/[0-9a-f]{20}/u,
|
||||
"Should return a 20 character long lowercase hex string."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#AddNewUserAPIKey", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should insert a new API key into the database", async (t) => {
|
||||
const data = await AddNewUserAPIKey({ id: 1 } as PrivateUserDocument);
|
||||
|
||||
t.not(data, null, "Return is not null");
|
||||
|
||||
prAssert(
|
||||
data,
|
||||
{
|
||||
_id: Prudence.any, // lazy
|
||||
apiKey: Prudence.regex(/[0-9a-f]{20}/u),
|
||||
assignedTo: Prudence.is(1),
|
||||
expireTime: Prudence.is(3176708633264),
|
||||
permissions: {
|
||||
selfkey: Prudence.is(true),
|
||||
admin: Prudence.is(false),
|
||||
},
|
||||
},
|
||||
"Data should match a public API key object"
|
||||
);
|
||||
|
||||
const inDatabase = await db["public-api-keys"].findOne({
|
||||
_id: data._id,
|
||||
});
|
||||
|
||||
t.strictSame(data, inDatabase, "Data from database is identical to data returned");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#ReinstateInvite", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should change the 'consumed' property of an invite to true.", async (t) => {
|
||||
// mock insert
|
||||
const inviteDoc = await db.invites.insert({
|
||||
code: "foobar",
|
||||
consumed: true,
|
||||
createdBy: 1,
|
||||
createdOn: 1,
|
||||
});
|
||||
|
||||
const response = await ReinstateInvite(inviteDoc);
|
||||
|
||||
t.equal(response.nModified, 1, "Should modify one document");
|
||||
|
||||
const invite2 = await db.invites.findOne({
|
||||
code: inviteDoc.code, // lol
|
||||
});
|
||||
|
||||
t.equal(invite2!.consumed, false, "Should no longer be consumed");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#CreateInviteCode", (t) => {
|
||||
t.match(CreateInviteCode(), /^[0-9a-f]{40}$/u, "Invite should be a 40 character hex string.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#AddNewInvite", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should create a new invite from a given user", async (t) => {
|
||||
const userDoc = await db.users.findOne({ id: 1 });
|
||||
|
||||
const result = await AddNewInvite(userDoc!);
|
||||
|
||||
t.equal(result.createdBy, userDoc!.id, "Invite should be created by the requesting user.");
|
||||
t.equal(result.consumed, false, "Invite should not be consumed.");
|
||||
|
||||
// was created +/- 6 seconds from now. This is perhaps too lenient, but we're only really testing its just around now ish.
|
||||
t.ok(Math.abs(result.createdOn - Date.now()) <= 6000, "Invite was created roughly now.");
|
||||
|
||||
t.match(result.code, /^[0-9a-f]{40}$/u, "Invite code should be a 40 character hex string.");
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#ValidateCaptcha", async (t) => {
|
||||
t.equal(
|
||||
await ValidateCaptcha("200", "bar", MockFetch({ status: 200 })),
|
||||
true,
|
||||
"Validates captcha when status return is 200"
|
||||
);
|
||||
|
||||
t.equal(
|
||||
await ValidateCaptcha("400", "bar", MockFetch({ status: 400 })),
|
||||
false,
|
||||
"Invalidates captcha when status return is not 200"
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.teardown(CloseMongoConnection);
|
||||
@@ -0,0 +1,156 @@
|
||||
import crypto from "crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import {
|
||||
InviteCodeDocument,
|
||||
PrivateUserDocument,
|
||||
PublicAPIKeyDocument,
|
||||
PublicUserDocument,
|
||||
} from "kamaitachi-common";
|
||||
import db from "../external/mongo/db";
|
||||
import { GetNextCounterValue } from "./db";
|
||||
import { InsertResult } from "monk";
|
||||
import CreateLogCtx from "./logger";
|
||||
import { FormatUserDoc } from "./user";
|
||||
import nodeFetch from "./fetch";
|
||||
import { CAPTCHA_SECRET_KEY } from "../secrets";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
const BCRYPT_SALT_ROUNDS = 12;
|
||||
|
||||
export const ValidatePassword = (self: unknown) =>
|
||||
(typeof self === "string" && self.length >= 8) || "Passwords must be 8 characters or more.";
|
||||
|
||||
export function CreateAPIKey(): string {
|
||||
return crypto.randomBytes(20).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Despite these functions doing ultimately the same thing, they're separate incase they ever need to,
|
||||
* you know, not do that.
|
||||
* @returns A string
|
||||
*/
|
||||
export function CreateInviteCode(): string {
|
||||
return crypto.randomBytes(20).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares a plaintext string of a users password to a hash.
|
||||
* @param plaintext The provided user input.
|
||||
* @param password The hash to compare against.
|
||||
*/
|
||||
export function PasswordCompare(plaintext: string, password: string) {
|
||||
return bcrypt.compare(plaintext, password);
|
||||
}
|
||||
|
||||
export function AddNewUserAPIKey(
|
||||
privateUserDoc: PrivateUserDocument
|
||||
): Promise<InsertResult<PublicAPIKeyDocument>> {
|
||||
const apikey = CreateAPIKey();
|
||||
|
||||
const publicApiKeyDoc: PublicAPIKeyDocument = {
|
||||
apiKey: apikey,
|
||||
assignedTo: privateUserDoc.id,
|
||||
expireTime: 3176708633264,
|
||||
permissions: {
|
||||
selfkey: true, // not sure what this was for but i'll keep it deliberately
|
||||
admin: false,
|
||||
},
|
||||
};
|
||||
|
||||
return db["public-api-keys"].insert(publicApiKeyDoc);
|
||||
}
|
||||
|
||||
export function ReinstateInvite(inviteDoc: InviteCodeDocument) {
|
||||
logger.info(`Reinstated Invite ${inviteDoc.code}`);
|
||||
return db.invites.update(
|
||||
{
|
||||
_id: inviteDoc._id,
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
consumed: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function AddNewInvite(user: PublicUserDocument) {
|
||||
const code = CreateInviteCode();
|
||||
|
||||
const result = await db.invites.insert({
|
||||
code,
|
||||
consumed: false,
|
||||
createdBy: user.id,
|
||||
createdOn: Date.now(),
|
||||
});
|
||||
|
||||
logger.info(`User ${FormatUserDoc(user)} created an invite.`);
|
||||
|
||||
if (!result) {
|
||||
logger.error(
|
||||
`Fatal error in creating ${FormatUserDoc(
|
||||
user
|
||||
)}'s invite code. Database refused key ${code}.`
|
||||
);
|
||||
throw new Error(
|
||||
`Fatal error in creating ${FormatUserDoc(
|
||||
user
|
||||
)}'s invite code. Database refused key ${code}.`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function AddNewUser(
|
||||
username: string,
|
||||
password: string,
|
||||
email: string
|
||||
): Promise<InsertResult<PrivateUserDocument>> {
|
||||
const hashedPassword = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
|
||||
|
||||
logger.verbose(`Hashed password for ${username}.`);
|
||||
|
||||
const userID = await GetNextCounterValue("users");
|
||||
|
||||
const userDoc: PrivateUserDocument = {
|
||||
id: userID,
|
||||
username: username,
|
||||
usernameLowercase: username.toLowerCase(),
|
||||
password: hashedPassword,
|
||||
about: "I'm a fairly nondescript person.",
|
||||
email: email,
|
||||
clan: null,
|
||||
friends: [],
|
||||
socialMedia: {},
|
||||
settings: {
|
||||
invisible: false,
|
||||
nsfwSplashes: false,
|
||||
},
|
||||
customBanner: false,
|
||||
customPfp: false,
|
||||
lastSeen: Date.now(), // lol
|
||||
permissions: {
|
||||
admin: false, // lol (2)
|
||||
},
|
||||
};
|
||||
|
||||
return db.users.insert(userDoc);
|
||||
}
|
||||
|
||||
export async function ValidateCaptcha(
|
||||
recaptcha: string,
|
||||
remoteAddr: string | undefined,
|
||||
fetch = nodeFetch
|
||||
) {
|
||||
const r = await fetch(
|
||||
`https://www.google.com/recaptcha/api/siteverify?secret=${CAPTCHA_SECRET_KEY}&response=${recaptcha}&remoteip=${remoteAddr}`
|
||||
);
|
||||
|
||||
if (r.status !== 200) {
|
||||
logger.verbose(`Failed GCaptcha response ${r.status}, ${r.body}`);
|
||||
}
|
||||
|
||||
return r.status === 200;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Game, integer, Playtypes, UserGameStats } from "kamaitachi-common";
|
||||
import { ClassData, gameClassValues } from "kamaitachi-common/js/game-classes";
|
||||
import db from "../external/mongo/db";
|
||||
import CreateLogCtx from "./logger";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
export function GetClassSetsForGamePT(game: Game, playtype: Playtypes[Game]) {
|
||||
// @ts-expect-error its confused about game+pt permutations
|
||||
return gameClassValues[game]?.[playtype] as Record<string, ClassData> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the provided class if it is greater than the one in userGameStats
|
||||
* @returns The provided class if it is greater, NULL if there is nothing
|
||||
* to compare to, and FALSE if it is worse or equal.
|
||||
*/
|
||||
export function ReturnClassIfGreater(
|
||||
game: Game,
|
||||
playtype: Playtypes[Game],
|
||||
setName: string,
|
||||
newClass: string,
|
||||
userGameStats?: UserGameStats | null
|
||||
) {
|
||||
const classSets = GetClassSetsForGamePT(game, playtype);
|
||||
|
||||
if (!classSets) {
|
||||
logger.error(`${game} ${playtype} Does not have any classes.`);
|
||||
throw new Error(`${game} ${playtype} Does not have any classes.`);
|
||||
}
|
||||
|
||||
const gcv: ClassData = classSets[setName];
|
||||
|
||||
if (gcv === undefined) {
|
||||
logger.error(`Invalid classKey ${setName}. Cannot process class.`);
|
||||
throw new Error(`Invalid classKey ${setName}. Cannot process class.`);
|
||||
}
|
||||
|
||||
const newClassInfo = gcv[newClass];
|
||||
|
||||
if (!userGameStats) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const val = newClassInfo.index;
|
||||
const pastClass = userGameStats.classes[setName];
|
||||
|
||||
if (!pastClass) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const pastVal = gcv[pastClass].index;
|
||||
|
||||
if (val > pastVal) {
|
||||
return newClass;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a user's class value if it is greater than the one in their
|
||||
* UserGameStats.
|
||||
* @returns False if nothing was updated.
|
||||
* Null if it was updated because there was nothing in UserGameStats to
|
||||
* compare to.
|
||||
* True if it was updated because it was better than UserGameStats.
|
||||
*/
|
||||
export async function UpdateClassIfGreater(
|
||||
userID: integer,
|
||||
game: Game,
|
||||
playtype: Playtypes[Game],
|
||||
classKey: string,
|
||||
newClass: string
|
||||
) {
|
||||
const userGameStats = await db["game-stats"].findOne({ userID, game, playtype });
|
||||
const isGreater = ReturnClassIfGreater(game, playtype, classKey, newClass, userGameStats);
|
||||
|
||||
if (isGreater === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (userGameStats) {
|
||||
await db["game-stats"].update(
|
||||
{ userID, game, playtype },
|
||||
{ $set: { [`classes.${classKey}`]: newClass } }
|
||||
);
|
||||
} else {
|
||||
// insert new game stats for this user - this is an awkward place
|
||||
// to call this - maybe we should call it elsewhere.
|
||||
await db["game-stats"].insert({
|
||||
userID,
|
||||
game,
|
||||
playtype,
|
||||
customRatings: {},
|
||||
lampRating: 0,
|
||||
rating: 0,
|
||||
classes: {
|
||||
classKey: newClass,
|
||||
},
|
||||
});
|
||||
logger.info(`Created new player gamestats for ${userID} ${game} (${playtype})`);
|
||||
}
|
||||
|
||||
if (isGreater === null) {
|
||||
// @todo #99 REDIS-IPC new class achieved
|
||||
|
||||
return null;
|
||||
} else {
|
||||
// @todo #99 REDIS-IPC class improved
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Difficulties, Game, integer, Playtypes, IDStrings } from "kamaitachi-common";
|
||||
import db from "../../external/mongo/db";
|
||||
|
||||
export function FindChartWithChartID(game: Game, chartID: string) {
|
||||
return db.charts[game].findOne({ chartID });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find chart with PlaytypeDifficulty. This only finds charts that have `isPrimary` set to true.
|
||||
* If you want to find charts that are not primary, you need to use PTDFVersion.
|
||||
* @see FindChartWithPTDFVersion
|
||||
*/
|
||||
export function FindChartWithPTDF<
|
||||
G extends Game = Game,
|
||||
P extends Playtypes[G] = Playtypes[G],
|
||||
I extends IDStrings = IDStrings
|
||||
>(game: G, songID: integer, playtype: P, difficulty: Difficulties[I]) {
|
||||
return db.charts[game].findOne({
|
||||
songID: songID,
|
||||
playtype: playtype,
|
||||
difficulty: difficulty,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find chart with Playtype, Difficulty and a given version. This does not necessarily return a chart that has
|
||||
* `isPrimary` set.
|
||||
*/
|
||||
export function FindChartWithPTDFVersion<
|
||||
G extends Game = Game,
|
||||
P extends Playtypes[G] = Playtypes[G],
|
||||
I extends IDStrings = IDStrings
|
||||
>(game: G, songID: integer, playtype: P, difficulty: Difficulties[I], version: string) {
|
||||
return db.charts[game].findOne({
|
||||
songID: songID,
|
||||
playtype: playtype,
|
||||
difficulty: difficulty,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a DDR Chart based on its "song hash".
|
||||
* Songs in DDR have a consistent checksum-like identifier used on the e-amusement website.
|
||||
* We can use this to locate a chart by combining it with a playtype and difficulty.
|
||||
*
|
||||
* Despite the potentially confusing name of "songHash", songs are NOT meant to store lookup-like tokens.
|
||||
* This is just for simplification reasons.
|
||||
* @param songHash The identifier for the song.
|
||||
* @param playtype The playtype for the chart.
|
||||
* @param difficulty The difficulty for the chart.
|
||||
*/
|
||||
export function FindDDRChartOnSongHash(
|
||||
songHash: string,
|
||||
// Technically both of these should be "ddr" instead of Game, but it proves hard to work with.
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
// note: this only works on accident because monk
|
||||
// allows any strings like "foo.bar".
|
||||
// We *should* normally cast this to ChartDocument<"ddr:SP" | "ddr:DP">
|
||||
return db.charts.ddr.findOne({
|
||||
"data.songHash": songHash,
|
||||
playtype,
|
||||
difficulty,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a BMS chart on either its md5sum or its sha256sum.
|
||||
* @param hash The md5 or sha256 hash to look for.
|
||||
*/
|
||||
export function FindBMSChartOnHash(hash: string) {
|
||||
return db.charts.bms.findOne({
|
||||
$or: [{ "data.hashMD5": hash }, { "data.hashSHA256": hash }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a chart on its in-game-ID, playtype and difficulty.
|
||||
*/
|
||||
export function FindChartOnInGameID(
|
||||
game: Game,
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
// @todo #101 throw an error if this is called with a game that doesn't
|
||||
// support InGameID.
|
||||
return db.charts[game].findOne({
|
||||
"data.inGameID": inGameID,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
|
||||
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
|
||||
*/
|
||||
export function FindIIDXChartOnInGameID(
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings]
|
||||
) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
"flags.2dxtra": false,
|
||||
isPrimary: true,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a non-custom chart on its in-game-ID, playtype and difficulty.
|
||||
* This explicitly ignores 2dxtra charts, and is necessary to use for iidx to disambiguate.
|
||||
*/
|
||||
export function FindIIDXChartOnInGameIDVersion(
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings],
|
||||
version: string
|
||||
) {
|
||||
// @todo #101 throw an error if this is called with a game that doesn't
|
||||
// support InGameID.
|
||||
return db.charts.iidx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
"flags.2dxtra": false,
|
||||
playtype,
|
||||
difficulty,
|
||||
versions: version,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a chart on its in-game-ID, playtype, difficulty and version.
|
||||
*/
|
||||
export function FindChartOnInGameIDVersion(
|
||||
game: Game,
|
||||
inGameID: number,
|
||||
playtype: Playtypes[Game],
|
||||
difficulty: Difficulties[IDStrings],
|
||||
version: string
|
||||
) {
|
||||
return db.charts[game].findOne({
|
||||
"data.inGameID": inGameID,
|
||||
versions: version,
|
||||
playtype,
|
||||
difficulty,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds an IIDX chart on its 2dxtra hash, which is the sha256 of the .1 buffer.
|
||||
*/
|
||||
export function FindIIDXChartWith2DXtraHash(hash: string) {
|
||||
return db.charts.iidx.findOne({
|
||||
"data.hashSHA256": hash,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an SDVX Chart on its in game ID. This exists to handle
|
||||
* oddities with SDVX difficulties - If "ANY_INF" is sent, it actually
|
||||
* refers to any of INF, GRV, HVN or VVD. This is because some services treat
|
||||
* all of those as the same difficulty, but we do not.
|
||||
*/
|
||||
export function FindSDVXChartOnInGameID(
|
||||
inGameID: number,
|
||||
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF"
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
|
||||
: difficulty;
|
||||
|
||||
return db.charts.sdvx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
difficulty: diffQuery,
|
||||
isPrimary: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function FindSDVXChartOnInGameIDVersion(
|
||||
inGameID: number,
|
||||
difficulty: "NOV" | "ADV" | "EXH" | "MXM" | "ANY_INF",
|
||||
version: string
|
||||
) {
|
||||
const diffQuery =
|
||||
difficulty === "ANY_INF"
|
||||
? { $in: ["INF", "GRV", "HVN", "VVD"] as Difficulties["sdvx:Single"][] }
|
||||
: difficulty;
|
||||
|
||||
return db.charts.sdvx.findOne({
|
||||
"data.inGameID": inGameID,
|
||||
difficulty: diffQuery,
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Game, AnySongDocument, integer } from "kamaitachi-common";
|
||||
import { FindOneResult } from "monk";
|
||||
import db from "../../external/mongo/db";
|
||||
import { EscapeStringRegexp } from "../util";
|
||||
|
||||
/**
|
||||
* Finds a song document for the given game with the given title (or alt-title).
|
||||
* This is NOT the preferred way to find a song, as encodings, and typos, make this
|
||||
* rather difficult. Prefer other functions!
|
||||
* @param game - The game to search upon.
|
||||
* @param title - The song title to match.
|
||||
* @returns AnySongDocument
|
||||
*/
|
||||
export function FindSongOnTitle(
|
||||
game: Game,
|
||||
title: string
|
||||
): Promise<FindOneResult<AnySongDocument>> {
|
||||
// @PERF: Performance should be tested here by having a utility field for all-titles.
|
||||
return db.songs[game].findOne({
|
||||
$or: [
|
||||
{
|
||||
title: title,
|
||||
},
|
||||
{
|
||||
"alt-titles": title,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a song on a song title case-insensitively.
|
||||
* This is needed for services that provide horrifically mutated string titles.
|
||||
*/
|
||||
export function FindSongOnTitleInsensitive(
|
||||
game: Game,
|
||||
title: string
|
||||
): Promise<FindOneResult<AnySongDocument>> {
|
||||
// @PERF: Performance should be tested here by having a utility field for all-titles.
|
||||
|
||||
const regex = new RegExp(`^${EscapeStringRegexp(title)}$`, "iu");
|
||||
return db.songs[game].findOne({
|
||||
$or: [
|
||||
{
|
||||
title: { $regex: regex },
|
||||
},
|
||||
{
|
||||
"alt-titles": { $regex: regex },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a song document based on the Kamaitachi songID. Depending on the database this might
|
||||
* also be the in-game-ID.
|
||||
* @param game - The game to search upon.
|
||||
* @param songID - The song ID to match.
|
||||
* @returns AnySongDocument
|
||||
*/
|
||||
export function FindSongOnID(game: Game, songID: integer): Promise<FindOneResult<AnySongDocument>> {
|
||||
return db.songs[game].findOne({
|
||||
id: songID,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { GetNextCounterValue } from "./db";
|
||||
import t from "tap";
|
||||
import db, { CloseMongoConnection } from "../external/mongo/db";
|
||||
import ResetDBState from "../test-utils/reset-db-state";
|
||||
|
||||
t.test("#GetNextCounterValue", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Increments on valid counter hit", async (t) => {
|
||||
const response = await GetNextCounterValue("real-counter");
|
||||
|
||||
// database starts with this at one
|
||||
t.equal(response, 2, "Counter should return the current number stored");
|
||||
|
||||
const dbData = await db.counters.findOne({
|
||||
counterName: "real-counter",
|
||||
});
|
||||
|
||||
t.equal(dbData!.value, 3, "Counter should increment after being hit");
|
||||
});
|
||||
|
||||
t.rejects(
|
||||
() => GetNextCounterValue("fake-counter"),
|
||||
"Could not find sequence document for fake-counter."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.teardown(CloseMongoConnection);
|
||||
@@ -0,0 +1,27 @@
|
||||
import db from "../external/mongo/db";
|
||||
import CreateLogCtx from "./logger";
|
||||
const logger = CreateLogCtx(__filename);
|
||||
import { integer } from "kamaitachi-common";
|
||||
|
||||
export async function GetNextCounterValue(counterName: string): Promise<integer> {
|
||||
const sequenceDoc = await db.counters.findOneAndUpdate(
|
||||
{
|
||||
counterName,
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
value: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
returnOriginal: true,
|
||||
}
|
||||
);
|
||||
|
||||
if (!sequenceDoc) {
|
||||
logger.error(`Could not find sequence document for ${counterName}`);
|
||||
throw new Error(`Could not find sequence document for ${counterName}.`);
|
||||
}
|
||||
|
||||
return sequenceDoc.value;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import nodeFetch from "node-fetch";
|
||||
|
||||
const fetch =
|
||||
process.env.NODE_ENV === "test"
|
||||
? () => {
|
||||
throw new Error("Cannot use real fetch inside testing env!");
|
||||
}
|
||||
: nodeFetch;
|
||||
|
||||
export type NodeFetch = (url: RequestInfo, init?: RequestInit | undefined) => Promise<Response>;
|
||||
|
||||
export default (fetch as unknown) as NodeFetch;
|
||||
@@ -0,0 +1,211 @@
|
||||
import t from "tap";
|
||||
import CreateLogCtx from "./logger";
|
||||
import { Testing511SPA } from "../test-utils/test-data";
|
||||
import { ChartDocument, FolderDocument } from "kamaitachi-common";
|
||||
import {
|
||||
CreateFolderChartLookup,
|
||||
GetFolderChartIDs,
|
||||
GetFolderCharts,
|
||||
ResolveFolderToCharts,
|
||||
} from "./folder";
|
||||
import db, { CloseMongoConnection } from "../external/mongo/db";
|
||||
import ResetDBState from "../test-utils/reset-db-state";
|
||||
import deepmerge from "deepmerge";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
t.todo("#CreateFolderChartLookup");
|
||||
t.todo("#GetFolderCharts");
|
||||
|
||||
t.test("#ResolveFolderToCharts", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Static Folder Tests", (t) => {
|
||||
t.test("Basic Static", async (t) => {
|
||||
const mockFolder = {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
type: "static",
|
||||
data: [Testing511SPA.chartID],
|
||||
};
|
||||
|
||||
const { charts } = await ResolveFolderToCharts(mockFolder as FolderDocument);
|
||||
|
||||
t.equal(charts.length, 1, "Should return exactly 1 chart.");
|
||||
t.equal(charts[0].chartID, Testing511SPA.chartID, "Should return 511 [SPA]");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Empty Static", async (t) => {
|
||||
const mockFolder = {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
type: "static",
|
||||
data: [],
|
||||
};
|
||||
|
||||
const { charts } = await ResolveFolderToCharts(
|
||||
(mockFolder as unknown) as FolderDocument
|
||||
);
|
||||
|
||||
t.equal(charts.length, 0, "Should return exactly 0 charts.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Songs Folder Tests", (t) => {
|
||||
t.test("Basic Song Query", async (t) => {
|
||||
const mockFolder = {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
type: "songs",
|
||||
data: {
|
||||
title: { $regex: /5\.1\.1/u },
|
||||
},
|
||||
};
|
||||
|
||||
const { charts } = await ResolveFolderToCharts(mockFolder as FolderDocument);
|
||||
|
||||
t.equal(charts.length, 1, "Should return exactly 1 chart.");
|
||||
t.equal(charts[0].chartID, Testing511SPA.chartID, "Should return 511 [SPA]");
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Invalid Song Query", async (t) => {
|
||||
const mockFolder = {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
type: "songs",
|
||||
data: {
|
||||
FieldThatDoesntExist: { $regex: /5\.1\.1/u },
|
||||
},
|
||||
};
|
||||
|
||||
const { charts } = await ResolveFolderToCharts(
|
||||
(mockFolder as unknown) as FolderDocument
|
||||
);
|
||||
|
||||
t.equal(charts.length, 0, "Should return exactly 0 charts.");
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Charts Folder Tests", (t) => {
|
||||
t.test("Basic Song Query", async (t) => {
|
||||
const mockFolder = {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
type: "charts",
|
||||
data: {
|
||||
songID: Testing511SPA.songID, // lol
|
||||
},
|
||||
};
|
||||
|
||||
const { charts } = await ResolveFolderToCharts(mockFolder as FolderDocument);
|
||||
|
||||
t.equal(charts.length, 1, "Should return exactly 1 chart.");
|
||||
t.equal(charts[0].chartID, Testing511SPA.chartID, "Should return 511 [SPA]");
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Filter", async (t) => {
|
||||
// add fake "NORMAL" 511 chart
|
||||
await db.charts.iidx.insert(
|
||||
deepmerge(Testing511SPA, {
|
||||
difficulty: "NORMAL",
|
||||
chartID: "FAKE_511_SPN",
|
||||
}) as ChartDocument<"iidx:SP">
|
||||
);
|
||||
|
||||
const mockFolder = {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
type: "charts",
|
||||
data: {
|
||||
songID: Testing511SPA.songID, // lol
|
||||
},
|
||||
};
|
||||
|
||||
const { charts } = await ResolveFolderToCharts(mockFolder as FolderDocument);
|
||||
|
||||
t.equal(charts.length, 2, "Should return exactly 2 charts.");
|
||||
t.strictSame(
|
||||
charts.map((e) => e.chartID),
|
||||
[Testing511SPA.chartID, "FAKE_511_SPN"],
|
||||
"Should return all 511 charts."
|
||||
);
|
||||
|
||||
const { charts: charts2 } = await ResolveFolderToCharts(mockFolder as FolderDocument, {
|
||||
difficulty: "NORMAL",
|
||||
});
|
||||
|
||||
t.equal(charts2.length, 1, "Should return exactly 1 chart.");
|
||||
t.equal(charts2[0].chartID, "FAKE_511_SPN", "Should only return 511 SPN");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Songs", async (t) => {
|
||||
const mockFolder = {
|
||||
game: "iidx",
|
||||
playtype: "SP",
|
||||
type: "charts",
|
||||
data: {
|
||||
songID: Testing511SPA.songID, // lol
|
||||
},
|
||||
};
|
||||
|
||||
const { charts, songs } = await ResolveFolderToCharts(
|
||||
mockFolder as FolderDocument,
|
||||
{},
|
||||
true
|
||||
);
|
||||
|
||||
t.equal(charts.length, 1, "Should return exactly 1 chart.");
|
||||
t.equal(charts[0].chartID, Testing511SPA.chartID, "Should return 511 [SPA]");
|
||||
|
||||
t.equal(songs.length, 1, "Should return exactly 1 song.");
|
||||
t.equal(songs[0].id, Testing511SPA.songID, "Should return 511's song.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#GetFolderChartIDs", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should use the folder-cache", async (t) => {
|
||||
await db["folder-chart-lookup"].insert(
|
||||
["a", "b", "c", "d"].map((e) => ({ chartID: e, folderID: "folder1" }))
|
||||
);
|
||||
|
||||
await db["folder-chart-lookup"].insert(
|
||||
["e", "f", "g", "h"].map((e) => ({ chartID: e, folderID: "folder2" }))
|
||||
);
|
||||
|
||||
let fcIDs = await GetFolderChartIDs("folder1");
|
||||
|
||||
t.strictSame(fcIDs, ["a", "b", "c", "d"], "Should return the right chartIDs for folder1");
|
||||
|
||||
fcIDs = await GetFolderChartIDs("folder2");
|
||||
|
||||
t.strictSame(fcIDs, ["e", "f", "g", "h"], "Should return the right chartIDs for folder2");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.teardown(CloseMongoConnection);
|
||||
@@ -0,0 +1,150 @@
|
||||
import db from "../external/mongo/db";
|
||||
import CreateLogCtx from "./logger";
|
||||
import { FolderDocument, AnyChartDocument, AnySongDocument } from "kamaitachi-common";
|
||||
import { FilterQuery } from "mongodb";
|
||||
import deepmerge from "deepmerge";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
// overloads!
|
||||
|
||||
export async function ResolveFolderToCharts(
|
||||
folder: FolderDocument,
|
||||
filter: FilterQuery<AnyChartDocument>,
|
||||
getSongs: true
|
||||
): Promise<{ songs: AnySongDocument[]; charts: AnyChartDocument[] }>;
|
||||
export async function ResolveFolderToCharts(
|
||||
folder: FolderDocument,
|
||||
filter?: FilterQuery<AnyChartDocument>,
|
||||
getSongs?: false
|
||||
): Promise<{ charts: AnyChartDocument[] }>;
|
||||
export async function ResolveFolderToCharts(
|
||||
folder: FolderDocument,
|
||||
filter: FilterQuery<AnyChartDocument> = {},
|
||||
getSongs = false
|
||||
): Promise<{ songs?: AnySongDocument[]; charts: AnyChartDocument[] }> {
|
||||
let songs: AnySongDocument[] | null = null;
|
||||
let charts: AnyChartDocument[];
|
||||
|
||||
if (folder.type === "static") {
|
||||
charts = await db.charts[folder.game].find(
|
||||
deepmerge(filter, {
|
||||
playtype: folder.playtype, // mandatory
|
||||
chartID: { $in: folder.data }, // @todo
|
||||
})
|
||||
);
|
||||
} else if (folder.type === "songs") {
|
||||
songs = await db.songs[folder.game].find(folder.data);
|
||||
|
||||
charts = await db.charts[folder.game].find(
|
||||
deepmerge(filter, {
|
||||
playtype: folder.playtype,
|
||||
songID: { $in: songs.map((e) => e.id) },
|
||||
})
|
||||
);
|
||||
} else if (folder.type === "charts") {
|
||||
const folderDataTransposed: Record<string, unknown> = {};
|
||||
|
||||
for (const key in folder.data) {
|
||||
folderDataTransposed[key.replace(/¬/gu, ".")] = folder.data[key];
|
||||
}
|
||||
|
||||
const fx = deepmerge.all([filter, { playtype: folder.playtype }, folderDataTransposed]);
|
||||
|
||||
charts = await db.charts[folder.game].find(fx);
|
||||
} else {
|
||||
// @ts-expect-error This is already a weird scenario. Shouldn't fail, though.
|
||||
logger.error(`Invalid folder at ${folder.folderID}. Cannot resolve.`, { folder });
|
||||
|
||||
// @ts-expect-error See above
|
||||
throw new Error(`Invalid folder ${folder.folderID}. Cannot resolve.`);
|
||||
}
|
||||
|
||||
if (getSongs) {
|
||||
if (songs) {
|
||||
return { songs, charts };
|
||||
}
|
||||
|
||||
songs = await db.songs[folder.game].find({
|
||||
id: { $in: charts.map((e) => e.songID) },
|
||||
});
|
||||
|
||||
return { songs, charts };
|
||||
}
|
||||
|
||||
return { charts };
|
||||
}
|
||||
|
||||
export async function GetFolderCharts(
|
||||
folder: FolderDocument,
|
||||
filter: FilterQuery<AnyChartDocument>,
|
||||
getSongs: true
|
||||
): Promise<{ songs: AnySongDocument[]; charts: AnyChartDocument[] }>;
|
||||
export async function GetFolderCharts(
|
||||
folder: FolderDocument,
|
||||
filter: FilterQuery<AnyChartDocument>,
|
||||
getSongs?: false
|
||||
): Promise<{ charts: AnyChartDocument[] }>;
|
||||
export async function GetFolderCharts(
|
||||
folder: FolderDocument,
|
||||
filter: FilterQuery<AnyChartDocument> = {},
|
||||
getSongs = false
|
||||
): Promise<{ songs?: AnySongDocument[]; charts: AnyChartDocument[] }> {
|
||||
const chartIDs = await GetFolderChartIDs(folder.folderID);
|
||||
|
||||
const charts = await db.charts[folder.game].find(
|
||||
deepmerge.all([{ playtype: folder.playtype }, { chartID: { $in: chartIDs } }, filter])
|
||||
);
|
||||
|
||||
if (getSongs) {
|
||||
const songs = await db.songs[folder.game].find({
|
||||
id: { $in: charts.map((e) => e.songID) },
|
||||
});
|
||||
|
||||
return { songs, charts };
|
||||
}
|
||||
|
||||
return { charts };
|
||||
}
|
||||
|
||||
export async function GetFolderChartIDs(folderID: string) {
|
||||
const chartIDs = await db["folder-chart-lookup"].find(
|
||||
{
|
||||
folderID,
|
||||
},
|
||||
{
|
||||
projection: {
|
||||
chartID: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return chartIDs.map((e) => e.chartID);
|
||||
}
|
||||
export async function CreateFolderChartLookup(folder: FolderDocument) {
|
||||
const { charts } = await ResolveFolderToCharts(folder, {}, false);
|
||||
|
||||
await db["folder-chart-lookup"].insert(
|
||||
charts.map((c) => ({
|
||||
folderID: folder.folderID,
|
||||
chartID: c.chartID,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the "folder-chart-lookup" cache. This is used to optimise
|
||||
* common use cases, such as retrieving chartIDs from a folder.
|
||||
*/
|
||||
export async function InitaliseFolderChartLookup() {
|
||||
logger.info(`Started InitialiseFolderChartLookup`);
|
||||
await db["folder-chart-lookup"].remove({});
|
||||
logger.info(`Flushed Cache.`);
|
||||
|
||||
const folders = await db.folders.find({});
|
||||
logger.info(`Reloading ${folders.length} folders.`);
|
||||
|
||||
await Promise.all(folders.map(CreateFolderChartLookup));
|
||||
|
||||
logger.info(`Completed InitialiseFolderChartLookup.`);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import t from "tap";
|
||||
import db, { CloseMongoConnection } from "../external/mongo/db";
|
||||
import CreateLogCtx from "./logger";
|
||||
import ResetDBState from "../test-utils/reset-db-state";
|
||||
import { EvaluateGoalForUser } from "./goal";
|
||||
import { GoalDocument } from "kamaitachi-common";
|
||||
import deepmerge from "deepmerge";
|
||||
import {
|
||||
HC511Goal,
|
||||
Testing511SPA,
|
||||
TestingIIDXFolderSP10,
|
||||
TestingIIDXSPScorePB,
|
||||
} from "../test-utils/test-data";
|
||||
import { CreateFolderChartLookup } from "./folder";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
t.test("#EvaluateGoalForUser", (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
t.test("Should correctly evaluate against single goals.", (t) => {
|
||||
t.beforeEach(async () => {
|
||||
await db["score-pbs"].insert(TestingIIDXSPScorePB);
|
||||
delete TestingIIDXSPScorePB._id;
|
||||
});
|
||||
|
||||
t.test("Should correctly evaluate goals if user succeeds.", async (t) => {
|
||||
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: true,
|
||||
outOf: 5,
|
||||
progress: 6,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "EX HARD CLEAR",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user succeeds."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should correctly evaluate goals if user fails.", async (t) => {
|
||||
await db["score-pbs"].update(
|
||||
{ userID: 1, chartID: Testing511SPA.chartID },
|
||||
{
|
||||
$set: {
|
||||
"scoreData.lamp": "CLEAR",
|
||||
"scoreData.lampIndex": 4,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: false,
|
||||
outOf: 5,
|
||||
progress: 4,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "CLEAR",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user fails."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should correctly evaluate goals when user has no score.", async (t) => {
|
||||
await db["score-pbs"].remove({});
|
||||
|
||||
const res = await EvaluateGoalForUser(HC511Goal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: false,
|
||||
outOf: 5,
|
||||
progress: null,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "NO DATA",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user has no score."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should correctly evaluate against multi goals.", (t) => {
|
||||
// @ts-expect-error ???
|
||||
// this goal is effectively "HC any of the charts"
|
||||
const multiGoal: GoalDocument = deepmerge(HC511Goal, {
|
||||
charts: {
|
||||
type: "multi",
|
||||
data: [Testing511SPA.chartID, "fake_other_chart_id"],
|
||||
},
|
||||
});
|
||||
|
||||
t.beforeEach(async () => {
|
||||
await db["score-pbs"].insert(TestingIIDXSPScorePB);
|
||||
delete TestingIIDXSPScorePB._id;
|
||||
|
||||
await db["score-pbs"].insert(
|
||||
deepmerge(TestingIIDXSPScorePB, {
|
||||
scoreData: { lamp: "CLEAR", lampIndex: 4 },
|
||||
chartID: "fake_other_chart_id",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
t.test("Should work if 511 is >= HARD CLEAR", async (t) => {
|
||||
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: true,
|
||||
outOf: 5,
|
||||
progress: 6,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "EX HARD CLEAR",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user succeeds."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should work if other chart is >= HARD CLEAR", async (t) => {
|
||||
await db["score-pbs"].update(
|
||||
{ userID: 1, chartID: Testing511SPA.chartID },
|
||||
{ $set: { "scoreData.lamp": "CLEAR", "scoreData.lampIndex": 4 } }
|
||||
);
|
||||
await db["score-pbs"].update(
|
||||
{ userID: 1, chartID: "fake_other_chart_id" },
|
||||
{ $set: { "scoreData.lamp": "HARD CLEAR", "scoreData.lampIndex": 5 } }
|
||||
);
|
||||
|
||||
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: true,
|
||||
outOf: 5,
|
||||
progress: 5,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "HARD CLEAR",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user succeeds."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should work if user has no scores", async (t) => {
|
||||
await db["score-pbs"].remove({});
|
||||
|
||||
const res = await EvaluateGoalForUser(multiGoal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: false,
|
||||
outOf: 5,
|
||||
progress: null,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "NO DATA",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user has no data."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should correctly evaluate against folder goals.", (t) => {
|
||||
// @ts-expect-error ???
|
||||
// this goal is effectively "HC any of the sp10s"
|
||||
const folderGoal: GoalDocument = deepmerge(HC511Goal, {
|
||||
charts: {
|
||||
type: "folder",
|
||||
data: "ed9d8c734447ce67d7135c0067441a98cc81aeaf",
|
||||
},
|
||||
});
|
||||
|
||||
t.beforeEach(async () => {
|
||||
await db["score-pbs"].insert(TestingIIDXSPScorePB);
|
||||
delete TestingIIDXSPScorePB._id;
|
||||
|
||||
await db["score-pbs"].insert(
|
||||
deepmerge(TestingIIDXSPScorePB, {
|
||||
scoreData: { lamp: "CLEAR", lampIndex: 4 },
|
||||
chartID: "other_sp10",
|
||||
})
|
||||
);
|
||||
|
||||
delete Testing511SPA._id;
|
||||
|
||||
await db.charts.iidx.insert([
|
||||
// @ts-expect-error ???
|
||||
deepmerge(Testing511SPA, {
|
||||
songID: 123,
|
||||
level: "9",
|
||||
chartID: "not_sp10",
|
||||
}),
|
||||
// @ts-expect-error ???
|
||||
deepmerge(Testing511SPA, {
|
||||
songID: 124,
|
||||
level: "10",
|
||||
chartID: "other_sp10",
|
||||
}),
|
||||
]);
|
||||
|
||||
await CreateFolderChartLookup(TestingIIDXFolderSP10);
|
||||
});
|
||||
|
||||
t.test("Should work if 511 is >= HARD CLEAR", async (t) => {
|
||||
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: true,
|
||||
outOf: 5,
|
||||
progress: 6,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "EX HARD CLEAR",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user succeeds."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should work if other chart is >= HARD CLEAR", async (t) => {
|
||||
await db["score-pbs"].update(
|
||||
{ userID: 1, chartID: Testing511SPA.chartID },
|
||||
{ $set: { "scoreData.lamp": "CLEAR", "scoreData.lampIndex": 4 } }
|
||||
);
|
||||
|
||||
await db["score-pbs"].update(
|
||||
{ userID: 1, chartID: "other_sp10" },
|
||||
{ $set: { "scoreData.lamp": "HARD CLEAR", "scoreData.lampIndex": 5 } }
|
||||
);
|
||||
|
||||
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: true,
|
||||
outOf: 5,
|
||||
progress: 5,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "HARD CLEAR",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user succeeds."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("Should work if user has no scores", async (t) => {
|
||||
await db["score-pbs"].remove({});
|
||||
|
||||
const res = await EvaluateGoalForUser(folderGoal, 1, logger);
|
||||
|
||||
t.strictSame(
|
||||
res,
|
||||
{
|
||||
achieved: false,
|
||||
outOf: 5,
|
||||
progress: null,
|
||||
outOfHuman: "HARD CLEAR",
|
||||
progressHuman: "NO DATA",
|
||||
},
|
||||
"Should correctly evaluate the goal if the user has no data."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.teardown(CloseMongoConnection);
|
||||
@@ -0,0 +1,181 @@
|
||||
import { GoalDocument, integer, PBScoreDocument, Game } from "kamaitachi-common";
|
||||
import { grades, lamps } from "kamaitachi-common/js/config";
|
||||
import db from "../external/mongo/db";
|
||||
import { KtLogger } from "./types";
|
||||
import { GetFolderChartIDs } from "./folder";
|
||||
import { FilterQuery } from "mongodb";
|
||||
|
||||
export interface EvaluatedGoalReturn {
|
||||
achieved: boolean;
|
||||
progress: number | null;
|
||||
outOf: number;
|
||||
progressHuman: string;
|
||||
outOfHuman: string;
|
||||
}
|
||||
|
||||
export async function EvaluateGoalForUser(
|
||||
goal: GoalDocument,
|
||||
userID: integer,
|
||||
logger: KtLogger
|
||||
): Promise<EvaluatedGoalReturn | null> {
|
||||
// First, we need to resolve the set of charts this
|
||||
// goal involves.
|
||||
const chartIDs = await ResolveGoalCharts(goal);
|
||||
|
||||
if (chartIDs === undefined) {
|
||||
logger.error(
|
||||
`Invalid goal ${goal.goalID} - has nonsense chartsType of ${goal.charts.type}, ignoring.`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// lets configure a "base" query for our requests.
|
||||
const scoreQuery: FilterQuery<PBScoreDocument> = {
|
||||
userID,
|
||||
// normally, this would be a VERY WORRYING line of code, but goal.criteria.key is guaranteed to be
|
||||
// within a specific set of fields.
|
||||
[goal.criteria.key]: { $gte: goal.criteria.value },
|
||||
};
|
||||
|
||||
if (chartIDs) {
|
||||
scoreQuery.chartID = { $in: chartIDs };
|
||||
}
|
||||
|
||||
// Next, we need to figure out our criteria.
|
||||
if (goal.criteria.mode === "single") {
|
||||
const res = await db["score-pbs"].findOne(scoreQuery);
|
||||
// hack, but guaranteed to work.
|
||||
const scoreDataKey = goal.criteria.key.split(".")[1] as
|
||||
| "lampIndex"
|
||||
| "gradeIndex"
|
||||
| "score"
|
||||
| "percent";
|
||||
|
||||
const outOfHuman = HumaniseGoalSingle(goal.game, goal.criteria.key, goal.criteria.value);
|
||||
|
||||
if (res) {
|
||||
return {
|
||||
achieved: true,
|
||||
outOf: goal.criteria.value,
|
||||
progress: res.scoreData[scoreDataKey],
|
||||
outOfHuman,
|
||||
progressHuman: HumaniseGoalSingle(
|
||||
goal.game,
|
||||
goal.criteria.key,
|
||||
res.scoreData[scoreDataKey]
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// if we weren't successful, we have to get the users next best score and put it up here
|
||||
// this is made infinitely easier by the existance of score-pbs.
|
||||
const nextBestQuery: FilterQuery<PBScoreDocument> = {
|
||||
userID,
|
||||
};
|
||||
|
||||
if (chartIDs) {
|
||||
nextBestQuery.chartID = { $in: chartIDs };
|
||||
}
|
||||
|
||||
const nextBestScore = await db["score-pbs"].findOne(nextBestQuery, {
|
||||
sort: { [goal.criteria.key]: -1 },
|
||||
});
|
||||
|
||||
if (!nextBestScore) {
|
||||
return {
|
||||
achieved: false,
|
||||
outOf: goal.criteria.value,
|
||||
progress: null,
|
||||
outOfHuman,
|
||||
progressHuman: "NO DATA",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
achieved: false,
|
||||
outOf: goal.criteria.value,
|
||||
outOfHuman,
|
||||
progress: nextBestScore.scoreData[scoreDataKey],
|
||||
progressHuman: HumaniseGoalSingle(
|
||||
goal.game,
|
||||
goal.criteria.key,
|
||||
nextBestScore.scoreData[scoreDataKey]
|
||||
),
|
||||
};
|
||||
} else if (goal.criteria.mode === "abs" || goal.criteria.mode === "proportion") {
|
||||
let count;
|
||||
|
||||
// abs -> Absolute mode, such as clear 10 charts.
|
||||
if (goal.criteria.mode === "abs") {
|
||||
count = goal.criteria.countNum;
|
||||
} else {
|
||||
// proportion -> Proportional mode, the value
|
||||
// is a multiplier for the amount of charts
|
||||
// available -- i.e. 0.1 * charts.
|
||||
|
||||
let totalChartCount;
|
||||
|
||||
if (chartIDs === null) {
|
||||
// edge case: proportion goals on "any"
|
||||
// charts (i.e. clear 20% of charts) need to
|
||||
// know how many charts the game has!
|
||||
totalChartCount = await db.charts[goal.game].count({ playtype: goal.playtype });
|
||||
} else {
|
||||
totalChartCount = chartIDs.length;
|
||||
}
|
||||
|
||||
count = Math.floor(goal.criteria.countNum * totalChartCount);
|
||||
}
|
||||
|
||||
const userCount = await db["score-pbs"].count(scoreQuery);
|
||||
|
||||
return {
|
||||
achieved: userCount >= count,
|
||||
progress: userCount,
|
||||
outOf: count,
|
||||
progressHuman: userCount.toString(),
|
||||
outOfHuman: userCount.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`Invalid goal: ${goal.goalID}, unknown criteria.mode ${goal.criteria.mode}, ignoring.`
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the set of charts involved with this goal.
|
||||
* @param goal
|
||||
* @returns An array of chartIDs, except if the goal chart type is "any", in which case, it returns null.
|
||||
*/
|
||||
function ResolveGoalCharts(goal: GoalDocument): Promise<string[]> | string[] | null | undefined {
|
||||
if (goal.charts.type === "single") {
|
||||
return [goal.charts.data];
|
||||
} else if (goal.charts.type === "multi") {
|
||||
return goal.charts.data;
|
||||
} else if (goal.charts.type === "folder") {
|
||||
return GetFolderChartIDs(goal.charts.data);
|
||||
} else if (goal.charts.type === "any") {
|
||||
return null; // special case.
|
||||
}
|
||||
}
|
||||
|
||||
type GoalKeys = GoalDocument["criteria"]["key"];
|
||||
|
||||
// @todo, #100 improve this (add things like BP for iidx, maybe, percents for scores?)
|
||||
function HumaniseGoalSingle(game: Game, key: GoalKeys, value: number): string {
|
||||
switch (key) {
|
||||
case "scoreData.gradeIndex":
|
||||
return grades[game][value];
|
||||
case "scoreData.lampIndex":
|
||||
return lamps[game][value];
|
||||
case "scoreData.percent":
|
||||
return `${value.toFixed(2)}%`;
|
||||
case "scoreData.score":
|
||||
return value.toString();
|
||||
default:
|
||||
throw new Error(`Broken goal - invalid key ${key}.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import t from "tap";
|
||||
import { CloseMongoConnection } from "../external/mongo/db";
|
||||
import CreateLogCtx, { Transports } from "./logger";
|
||||
|
||||
t.test("Logger Tests", (t) => {
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
Transports[2].level = "debug"; // lol
|
||||
|
||||
logger.debug("Debug Message Test");
|
||||
logger.verbose("Verbose Message Test");
|
||||
logger.info("Info Message Test");
|
||||
logger.warn("Warning Message Test");
|
||||
logger.error("Error Message Test");
|
||||
logger.severe("Severe Message Test");
|
||||
logger.crit("Critical Message Test");
|
||||
|
||||
Transports[2].level = process.env.LOG_LEVEL ?? "info";
|
||||
|
||||
logger.debug("This message shouldn't appear.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.teardown(CloseMongoConnection);
|
||||
@@ -0,0 +1,155 @@
|
||||
import winston, { format, transports } from "winston";
|
||||
import { KtLogger } from "./types";
|
||||
|
||||
const level = process.env.LOG_LEVEL ?? "info";
|
||||
|
||||
// const IN_PROD = process.env.NODE_ENV === "production";
|
||||
const IN_TESTING = process.env.NODE_ENV === "test";
|
||||
|
||||
const formatExcessProperties = (meta: Record<string, unknown>) => {
|
||||
let i = 0;
|
||||
for (const key in meta) {
|
||||
const val = meta[key];
|
||||
|
||||
if (val instanceof Error) {
|
||||
meta[key] = { message: val.message, stack: val.stack };
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!i) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return ` ${JSON.stringify(meta)}`;
|
||||
};
|
||||
|
||||
const formatExcessPropertiesNoStack = (meta: Record<string, unknown>, omitKeys: string[] = []) => {
|
||||
let i = 0;
|
||||
const realMeta: Record<string, unknown> = {};
|
||||
|
||||
for (const key in meta) {
|
||||
if (omitKeys.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const val = meta[key];
|
||||
|
||||
if (val instanceof Error) {
|
||||
realMeta[key] = { message: val.message };
|
||||
} else {
|
||||
realMeta[key] = val;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
if (!i) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return ` ${JSON.stringify(realMeta)}`;
|
||||
};
|
||||
|
||||
const ktblackPrintf = format.printf(
|
||||
({ level, message, context = "ktblack-root", timestamp, ...meta }) =>
|
||||
`${timestamp} [${
|
||||
Array.isArray(context) ? context.join(" | ") : context
|
||||
}] ${level}: ${message}${formatExcessProperties(meta)}`
|
||||
);
|
||||
|
||||
const ktblackConsolePrintf = format.printf(
|
||||
({ level, message, context = "ktblack-root", timestamp, hideFromConsole, ...meta }) =>
|
||||
`${timestamp} [${
|
||||
Array.isArray(context) ? context.join(" | ") : context
|
||||
}] ${level}: ${message}${formatExcessPropertiesNoStack(meta, hideFromConsole)}`
|
||||
);
|
||||
|
||||
winston.addColors({
|
||||
crit: ["bgRed", "black"],
|
||||
severe: ["bgWhite", "red"],
|
||||
error: ["red"],
|
||||
warn: ["yellow"],
|
||||
info: ["blue"],
|
||||
verbose: ["cyan"],
|
||||
debug: ["white"],
|
||||
});
|
||||
|
||||
const baseFormatRoute = format.combine(
|
||||
format.timestamp({
|
||||
format: "YYYY-MM-DD HH:mm:ss",
|
||||
})
|
||||
);
|
||||
|
||||
const defaultFormatRoute = format.combine(
|
||||
baseFormatRoute,
|
||||
format.errors({ stack: false }),
|
||||
ktblackPrintf
|
||||
);
|
||||
|
||||
const consoleFormatRoute = format.combine(
|
||||
baseFormatRoute,
|
||||
format.errors({ stack: false }),
|
||||
ktblackConsolePrintf,
|
||||
format.colorize({
|
||||
all: true,
|
||||
})
|
||||
);
|
||||
|
||||
let tports = [];
|
||||
|
||||
if (IN_TESTING) {
|
||||
tports = [
|
||||
new transports.File({
|
||||
filename: "logs/ktblack-tests-error.log",
|
||||
level: "error",
|
||||
format: defaultFormatRoute,
|
||||
}),
|
||||
new transports.File({ filename: "logs/ktblack-tests.log", format: defaultFormatRoute }),
|
||||
new transports.Console({
|
||||
format: consoleFormatRoute,
|
||||
}),
|
||||
];
|
||||
} else {
|
||||
tports = [
|
||||
new transports.File({
|
||||
filename: "logs/ktblack-error.log",
|
||||
level: "error",
|
||||
format: defaultFormatRoute,
|
||||
}),
|
||||
new transports.File({ filename: "logs/ktblack.log", format: defaultFormatRoute }),
|
||||
new transports.Console({
|
||||
format: consoleFormatRoute,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export const rootLogger = winston.createLogger({
|
||||
levels: {
|
||||
crit: 0, // entire process termination is necessary
|
||||
severe: 1, // something is wrong, and more than one function is affected (such as a failed assertion that is definitely expected to be true).
|
||||
error: 2, // function call (or related process) has failed unexpectedly
|
||||
warn: 3, // function call has hit something it didn't want, but can recover
|
||||
info: 4, // something has happened that is expected, but worth logging
|
||||
verbose: 5, // something has happened
|
||||
debug: 6, // glorified console.log debugging
|
||||
},
|
||||
level,
|
||||
format: defaultFormatRoute,
|
||||
transports: tports,
|
||||
});
|
||||
|
||||
function CreateLogCtx(filename: string, lg = rootLogger): KtLogger {
|
||||
return lg.child({
|
||||
context: filename.replace(new RegExp(`^${process.cwd()}/`, "u"), ""),
|
||||
}) as KtLogger;
|
||||
}
|
||||
|
||||
export function AppendLogCtx(context: string, lg: KtLogger): KtLogger {
|
||||
const newContext = [...lg.defaultMeta.context, context];
|
||||
|
||||
return lg.child({ context: newContext }) as KtLogger;
|
||||
}
|
||||
|
||||
export const Transports = tports;
|
||||
|
||||
export default CreateLogCtx;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { MilestoneDocument, integer, GoalImportInfo } from "kamaitachi-common";
|
||||
|
||||
/**
|
||||
* Processes and updates a user's milestones from their Goal Import Info (i.e. what is returned
|
||||
* about goals from imports)
|
||||
*/
|
||||
export function ProcessMilestoneFromGII(
|
||||
milestone: MilestoneDocument,
|
||||
gii: Map<string, GoalImportInfo["new"]>
|
||||
) {
|
||||
const goalIDs = GetGoalIDsFromMilestone(milestone);
|
||||
|
||||
let progress = 0;
|
||||
|
||||
for (const goalID of goalIDs) {
|
||||
const userInfo = gii.get(goalID);
|
||||
|
||||
if (!userInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!userInfo.achieved) {
|
||||
continue;
|
||||
}
|
||||
|
||||
progress++;
|
||||
}
|
||||
|
||||
const outOf = CalculateMilestoneOutOf(milestone, goalIDs);
|
||||
|
||||
// milestone achieved!
|
||||
if (progress >= outOf) {
|
||||
return { achieved: true, progress };
|
||||
}
|
||||
|
||||
return { achieved: false, progress };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the goalID documents in a single array from the
|
||||
* nested structure of milestones.
|
||||
*/
|
||||
function GetGoalIDsFromMilestone(milestone: MilestoneDocument) {
|
||||
// this sucks - maybe a nicer way to do this, because nested
|
||||
// maps are just ugly
|
||||
return milestone.milestoneData.map((e) => e.goals.map((e) => e.goalID)).flat(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out how many goals need to be achieved for this
|
||||
* milestone to be considered completed.
|
||||
*/
|
||||
function CalculateMilestoneOutOf(milestone: MilestoneDocument, goalIDs: string[]): integer {
|
||||
if (milestone.criteria.type === "all") {
|
||||
return goalIDs.length;
|
||||
} else if (milestone.criteria.type === "abs") {
|
||||
if (milestone.criteria.value === null) {
|
||||
throw new Error(
|
||||
`Invalid milestone ${milestone.milestoneID} - abs and null are not compatible.`
|
||||
);
|
||||
}
|
||||
|
||||
return milestone.criteria.value!;
|
||||
} else if (milestone.criteria.type === "proportion") {
|
||||
if (milestone.criteria.value === null) {
|
||||
throw new Error(
|
||||
`Invalid milestone ${milestone.milestoneID} - proportion and null are not compatible.`
|
||||
);
|
||||
}
|
||||
|
||||
return Math.floor(milestone.criteria.value * goalIDs.length);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Invalid milestone.criteria.type of ${milestone.criteria.type} -- milestoneID ${milestone.milestoneID}`
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// @todo #118
|
||||
|
||||
import { RequestHandler } from "express";
|
||||
import multer, { MulterError } from "multer";
|
||||
import CreateLogCtx from "./logger";
|
||||
|
||||
const defaultLogger = CreateLogCtx(__filename);
|
||||
|
||||
export const DefaultMulterUpload = multer({ limits: { fileSize: 1024 * 1024 * 16 } }); // 16MB
|
||||
|
||||
export const CreateMulterSingleUploadMiddleware = (
|
||||
fieldName: string,
|
||||
logger = defaultLogger
|
||||
): RequestHandler => {
|
||||
const UploadMW = DefaultMulterUpload.single(fieldName);
|
||||
|
||||
return (req, res, next) => {
|
||||
UploadMW(req, res, (err: unknown) => {
|
||||
if (err instanceof MulterError) {
|
||||
logger.info(`Multer Error.`, { err });
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description:
|
||||
"File provided was too large, corrupt, or provided in the wrong field.",
|
||||
});
|
||||
} else if (err) {
|
||||
logger.error(`Unknown file import error: ${err}`, { err });
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `An internal server error has occured.`,
|
||||
});
|
||||
}
|
||||
|
||||
return next();
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
// @todo #118 Investigate why this import triggers a circular dependency according to madge.
|
||||
import p, { PrudenceError, ValidSchemaValue } from "prudence";
|
||||
|
||||
export function FormatPrError(err: PrudenceError, foreword = "Error") {
|
||||
const receivedText =
|
||||
typeof err.userVal === "object" && err.userVal !== null
|
||||
? ""
|
||||
: ` | Received ${err.userVal} [${err.userVal === null ? "null" : typeof err.userVal}]`;
|
||||
|
||||
return `${foreword}: ${err.keychain} | ${err.message}${receivedText}.`;
|
||||
}
|
||||
|
||||
export const optNull = (v: ValidSchemaValue) => p.optional(p.nullable(v));
|
||||
@@ -0,0 +1,16 @@
|
||||
import db from "../external/mongo/db";
|
||||
import { integer } from "kamaitachi-common";
|
||||
|
||||
export function GetPBOnChart(userID: integer, chartID: string) {
|
||||
return db["score-pbs"].findOne({
|
||||
userID,
|
||||
chartID,
|
||||
});
|
||||
}
|
||||
|
||||
export function GetServerRecordOnChart(chartID: string) {
|
||||
return db["score-pbs"].findOne({
|
||||
chartID,
|
||||
"rankingData.rank": 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import t from "tap";
|
||||
import db, { CloseMongoConnection } from "../external/mongo/db";
|
||||
import ResetDBState from "../test-utils/reset-db-state";
|
||||
import { GetScoresFromSession } from "./session";
|
||||
|
||||
t.test("#GetScoresFromSession", async (t) => {
|
||||
t.beforeEach(ResetDBState);
|
||||
|
||||
const exampleSession = await db.sessions.findOne();
|
||||
|
||||
const scores = await GetScoresFromSession(exampleSession!);
|
||||
|
||||
t.equal(
|
||||
scores.length,
|
||||
exampleSession!.scoreInfo.length,
|
||||
"Should return the same amount of scores as the session."
|
||||
);
|
||||
|
||||
for (let i = 0; i < exampleSession!.scoreInfo.length; i++) {
|
||||
t.equal(
|
||||
scores[i].scoreID,
|
||||
exampleSession!.scoreInfo[i].scoreID,
|
||||
"Should return the scores requested."
|
||||
);
|
||||
}
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.teardown(CloseMongoConnection);
|
||||
@@ -0,0 +1,24 @@
|
||||
import { SessionDocument, ScoreDocument, SessionScoreInfo } from "kamaitachi-common";
|
||||
import { Condition } from "mongodb";
|
||||
import db from "../external/mongo/db";
|
||||
|
||||
/**
|
||||
* Returns all the score documents inside a session.
|
||||
* @param session The session to retrieve the score documents of.
|
||||
*/
|
||||
export function GetScoresFromSession(session: SessionDocument) {
|
||||
return db.scores.find({
|
||||
scoreID: { $in: session.scoreInfo.map((e) => e.scoreID) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session a score belongs to, if there is one. A score can only be part of one session implicitly.
|
||||
* @param score The score to return the associated session of.
|
||||
*/
|
||||
export function GetSessionFromScore(score: ScoreDocument) {
|
||||
return db.sessions.findOne({
|
||||
// ??? another bug in monks types i think
|
||||
scoreInfo: { scoreID: score.scoreID } as Condition<SessionScoreInfo>,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import crypto from "crypto";
|
||||
import { AnyChartDocument, Game, Playtypes } from "kamaitachi-common";
|
||||
import db from "../external/mongo/db";
|
||||
import CreateLogCtx from "./logger";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
export function CalculateTierlistDataID(
|
||||
chartID: string,
|
||||
type: "score" | "lamp" | "grade",
|
||||
key: string | null,
|
||||
tierlistID: string
|
||||
) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(`${chartID}|${type}|${key}|${tierlistID}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function GetDefaultTierlist(game: Game, playtype: Playtypes[Game]) {
|
||||
return db.tierlists.findOne({
|
||||
game,
|
||||
playtype,
|
||||
isDefault: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function GetDefaultTierlistID(game: Game, playtype: Playtypes[Game]) {
|
||||
const tierlistData = await GetDefaultTierlist(game, playtype);
|
||||
|
||||
if (!tierlistData) {
|
||||
logger.warn(
|
||||
`Attempted to GetTierlistData for ${game} ${playtype}, but no default tierlist is set.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return tierlistData.tierlistID;
|
||||
}
|
||||
|
||||
export async function GetOneTierlistData(
|
||||
game: Game,
|
||||
chart: AnyChartDocument,
|
||||
type: "score" | "lamp" | "grade",
|
||||
key: string | null,
|
||||
tierlistID?: string
|
||||
) {
|
||||
const tlID = tierlistID ?? (await GetDefaultTierlistID(game, chart.playtype));
|
||||
|
||||
const query: Record<string, unknown> = {
|
||||
tierlistID: tlID,
|
||||
chartID: chart.chartID,
|
||||
type,
|
||||
key,
|
||||
};
|
||||
|
||||
return db["tierlist-data"].findOne(query);
|
||||
}
|
||||
|
||||
export async function GetAllTierlistDataOfType(
|
||||
game: Game,
|
||||
chart: AnyChartDocument,
|
||||
type: "score" | "lamp" | "grade",
|
||||
tierlistID?: string
|
||||
) {
|
||||
const tlID = tierlistID ?? (await GetDefaultTierlistID(game, chart.playtype));
|
||||
|
||||
const query: Record<string, unknown> = {
|
||||
tierlistID: tlID,
|
||||
chartID: chart.chartID,
|
||||
type,
|
||||
};
|
||||
|
||||
return db["tierlist-data"].find(query);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { integer, USCAuthDocument, ChartDocument } from "kamaitachi-common";
|
||||
import { Logger, LeveledLogMethod } from "winston";
|
||||
|
||||
declare module "express-session" {
|
||||
// Inject additional properties on express-session
|
||||
interface SessionData {
|
||||
ktchi: KtchiSessionData;
|
||||
}
|
||||
}
|
||||
|
||||
export interface KtchiSessionData {
|
||||
userID: integer;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export interface KTFailResponse {
|
||||
success: false;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface KTSuccessResponse {
|
||||
success: true;
|
||||
description: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type KTReponse = KTFailResponse | KTSuccessResponse;
|
||||
|
||||
export type KtLogger = Logger & { severe: LeveledLogMethod };
|
||||
|
||||
/**
|
||||
* 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">;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import t from "tap";
|
||||
import { CloseMongoConnection } from "../external/mongo/db";
|
||||
import { PRUDENCE_PRIVATE_USER, PRUDENCE_PUBLIC_USER } from "../external/mongo/schemas";
|
||||
import { prAssert } from "../test-utils/asserts";
|
||||
import { GetUserCaseInsensitive, PRIVATEINFO_GetUserCaseInsensitive, FormatUserDoc } from "./user";
|
||||
import { PublicUserDocument } from "kamaitachi-common";
|
||||
|
||||
t.test("#GetUserCaseInsensitive", (t) => {
|
||||
t.test("Should return the user for an exact username", async (t) => {
|
||||
const result = await GetUserCaseInsensitive("test_zkldi");
|
||||
|
||||
t.not(result, null, "Should not return null");
|
||||
|
||||
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
|
||||
|
||||
prAssert(result, PRUDENCE_PUBLIC_USER, "Should return a conforming PublicUserDocument");
|
||||
|
||||
// @ts-expect-error yeah
|
||||
t.equal(result.password, undefined, "Should not return password");
|
||||
// @ts-expect-error yeah
|
||||
t.equal(result.email, undefined, "Should not return email");
|
||||
});
|
||||
|
||||
t.test("Should return the user for an incorrectly cased username", async (t) => {
|
||||
const result = await GetUserCaseInsensitive("tesT_ZkLdi");
|
||||
|
||||
t.not(result, null, "Should not return null");
|
||||
|
||||
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
|
||||
|
||||
prAssert(result, PRUDENCE_PUBLIC_USER, "Should return a conforming PublicUserDocument");
|
||||
|
||||
// @ts-expect-error yeah
|
||||
t.equal(result.password, undefined, "Should not return password");
|
||||
// @ts-expect-error yeah
|
||||
t.equal(result.email, undefined, "Should not return email");
|
||||
});
|
||||
|
||||
t.test("Should not return the user for a username that does not exist", async (t) => {
|
||||
const result = await GetUserCaseInsensitive("foobar");
|
||||
|
||||
t.equal(result, null, "Should return null");
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#PRIVATEINFO_GetUserCaseInsensitive", (t) => {
|
||||
t.test("Should return the user for an exact username", async (t) => {
|
||||
const result = await PRIVATEINFO_GetUserCaseInsensitive("test_zkldi");
|
||||
|
||||
t.not(result, null, "Should not return null");
|
||||
|
||||
t.equal(result!.username, "test_zkldi", "Should return test_rzkldi");
|
||||
|
||||
prAssert(result, PRUDENCE_PRIVATE_USER, "Should return a conforming PrivateUserDocument");
|
||||
});
|
||||
|
||||
t.test("Should return the user for an incorrectly cased username", async (t) => {
|
||||
const result = await PRIVATEINFO_GetUserCaseInsensitive("tesT_ZkLdi");
|
||||
|
||||
t.not(result, null, "Should not return null");
|
||||
|
||||
t.equal(result!.username, "test_zkldi", "Should return test_zkldi");
|
||||
|
||||
prAssert(result, PRUDENCE_PRIVATE_USER, "Should return a conforming PrivateUserDocument");
|
||||
});
|
||||
|
||||
t.test("Should not return the user for a username that does not exist", async (t) => {
|
||||
const result = await PRIVATEINFO_GetUserCaseInsensitive("foobar");
|
||||
|
||||
t.equal(result, null, "Should return null");
|
||||
});
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.test("#FormatUserDoc", (t) => {
|
||||
t.equal(
|
||||
FormatUserDoc({ username: "zkldi", id: 123 } as PublicUserDocument),
|
||||
"zkldi (#123)",
|
||||
"Should format a user document into username #id format."
|
||||
);
|
||||
|
||||
t.end();
|
||||
});
|
||||
|
||||
t.teardown(CloseMongoConnection);
|
||||
@@ -0,0 +1,133 @@
|
||||
import { integer, PublicUserDocument } from "kamaitachi-common";
|
||||
import { FindOneResult } from "monk";
|
||||
import db from "../external/mongo/db";
|
||||
import CreateLogCtx from "./logger";
|
||||
|
||||
const logger = CreateLogCtx(__filename);
|
||||
|
||||
const OMIT_PRIVATE_USER_RETURNS = {
|
||||
password: 0,
|
||||
email: 0,
|
||||
// legacy protection - this field does not exist on user documents anymore, but it did, and it *did* hold personal information.
|
||||
integrations: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a user based on their username case-insensitively.
|
||||
* @param username The username of the user.
|
||||
* @returns PublicUserDocument | null
|
||||
*/
|
||||
export function GetUserCaseInsensitive(
|
||||
username: string
|
||||
): Promise<FindOneResult<PublicUserDocument>> {
|
||||
return db.users.findOne(
|
||||
{
|
||||
usernameLowercase: username.toLowerCase(),
|
||||
},
|
||||
{
|
||||
projection: OMIT_PRIVATE_USER_RETURNS,
|
||||
}
|
||||
) as Promise<FindOneResult<PublicUserDocument>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns GetUserCaseInsensitive, but without the private field omission.
|
||||
* @see GetUserCaseInsensitive
|
||||
* @param username The username of the user.
|
||||
* @returns PrivateUserDocument | null
|
||||
*/
|
||||
export function PRIVATEINFO_GetUserCaseInsensitive(username: string) {
|
||||
return db.users.findOne({
|
||||
usernameLowercase: username.toLowerCase(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user from their userID.
|
||||
* @param userID The userID to retrieve the user document of.
|
||||
* @returns PublicUserDocument | null
|
||||
*/
|
||||
export function GetUserWithID(userID: integer): Promise<FindOneResult<PublicUserDocument>> {
|
||||
return db.users.findOne(
|
||||
{
|
||||
id: userID,
|
||||
},
|
||||
{
|
||||
projection: OMIT_PRIVATE_USER_RETURNS,
|
||||
}
|
||||
) as Promise<FindOneResult<PublicUserDocument>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a user document that is expected to exist.
|
||||
* If the user document is not found, a severe error is logged, and this
|
||||
* function throws.
|
||||
*
|
||||
* @param userID The userID to retrieve the user document of.
|
||||
* @returns PublicUserDocument
|
||||
*/
|
||||
export async function GetUserWithIDGuaranteed(userID: integer): Promise<PublicUserDocument> {
|
||||
const userDoc = await GetUserWithID(userID);
|
||||
|
||||
if (!userDoc) {
|
||||
logger.severe(
|
||||
`User ${userID} does not have an associated user document, but one was expected.`
|
||||
);
|
||||
throw new Error(
|
||||
`User ${userID} does not have an associated user document, but one was expected.`
|
||||
);
|
||||
}
|
||||
|
||||
return userDoc;
|
||||
}
|
||||
|
||||
/**
|
||||
* GetUserWithID, but return personal information, too.
|
||||
* @see GetUserWithID
|
||||
* @param userID
|
||||
* @returns PrivateUserDocument | null
|
||||
*/
|
||||
export function PRIVATEINFO_GetUserWithID(userID: integer) {
|
||||
return db.users.findOne({
|
||||
id: userID,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a user based on either their username case-insensitively, or a direct lookup of their ID.
|
||||
* This is used in URLs to resolve the passed user.
|
||||
* @param usernameOrID
|
||||
*/
|
||||
export function ResolveUser(usernameOrID: string) {
|
||||
// user ID passed
|
||||
if (usernameOrID.match(/^[0-9]$/u)) {
|
||||
const intID = Number(usernameOrID);
|
||||
|
||||
return db.users.findOne(
|
||||
{
|
||||
id: intID,
|
||||
},
|
||||
{
|
||||
projection: OMIT_PRIVATE_USER_RETURNS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return db.users.findOne(
|
||||
{
|
||||
usernameLowercase: usernameOrID,
|
||||
},
|
||||
{
|
||||
projection: OMIT_PRIVATE_USER_RETURNS,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a formatted string indicating the user. This is used for logging.
|
||||
* (This is not in user.ts to avoid a circular reference issue with logger.ts<->db.ts)
|
||||
* @param userdoc The user document to format.
|
||||
*/
|
||||
export function FormatUserDoc(userdoc: PublicUserDocument) {
|
||||
return `${userdoc.username} (#${userdoc.id})`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import t from "tap";
|
||||
import { GetMilisecondsSince } from "./util";
|
||||
|
||||
t.test("#GetMilisecondsSince", (t) => {
|
||||
const time = GetMilisecondsSince(10n);
|
||||
t.ok(typeof time === "number" && time > 0, "Should return a number greater than 0.");
|
||||
|
||||
t.end();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
// https://github.com/sindresorhus/escape-string-regexp/blob/main/index.js
|
||||
// the developer of this has migrated everything to Force ES6 style modules,
|
||||
// which really really messes with a lot of the ecosystem.
|
||||
// shim.
|
||||
|
||||
export function EscapeStringRegexp(string: string) {
|
||||
if (typeof string !== "string") {
|
||||
throw new TypeError("Expected a string");
|
||||
}
|
||||
|
||||
// Escape characters with special meaning either inside or outside character sets.
|
||||
// Use a simple backslash escape when it's always valid, and a `\xnn` escape when the simpler form would be disallowed by Unicode patterns' stricter grammar.
|
||||
return string.replace(/[|\\{}()[\]^$+*?.]/gu, "\\$&").replace(/-/gu, "\\x2d");
|
||||
}
|
||||
|
||||
export function ParseEA3SoftID(ver: string) {
|
||||
const a = ver.split(":");
|
||||
|
||||
if (a.length !== 5) {
|
||||
throw new Error(`Invalid Version Code. Had ${a.length} components.`);
|
||||
}
|
||||
|
||||
if (!ver.match(/^[A-Z0-9]{3}:[A-Z]:[A-Z]:[A-Z]:[0-9]{10}$/u)) {
|
||||
throw new Error(`Invalid Version Code.`);
|
||||
}
|
||||
|
||||
return {
|
||||
model: a[0],
|
||||
dest: a[1], // region
|
||||
spec: a[2],
|
||||
rev: a[3],
|
||||
ext: a[4],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a process.hrtime.bigint(), and returns the miliseconds elapsed since it.
|
||||
* This function will not work if more than 100(ish) days have passed since the first reference.
|
||||
*/
|
||||
export function GetMilisecondsSince(ref: bigint) {
|
||||
return Number(process.hrtime.bigint() - ref) / 1e6;
|
||||
}
|
||||
|
||||
export function Random20Hex() {
|
||||
return crypto.randomBytes(20).toString("hex");
|
||||
}
|
||||
|
||||
export function MStoS(ms: number) {
|
||||
return Math.floor(ms / 1000);
|
||||
}
|
||||
Reference in New Issue
Block a user