rename src/common to src/utils

This commit is contained in:
zkldi
2021-05-25 17:56:41 +01:00
parent 09f5af727a
commit 7f1829daae
112 changed files with 146 additions and 146 deletions
+132
View File
@@ -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);
+156
View File
@@ -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;
}
+114
View File
@@ -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;
}
}
+211
View File
@@ -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,
});
}
+65
View File
@@ -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,
});
}
+30
View File
@@ -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);
+27
View File
@@ -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;
}
+12
View File
@@ -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;
+211
View File
@@ -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);
+150
View File
@@ -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.`);
}
+299
View File
@@ -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);
+181
View File
@@ -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}.`);
}
}
+25
View File
@@ -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);
+155
View File
@@ -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;
+77
View File
@@ -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}`
);
}
+39
View File
@@ -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();
});
};
};
+13
View File
@@ -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));
+16
View File
@@ -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,
});
}
+30
View File
@@ -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);
+24
View File
@@ -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>,
});
}
+75
View File
@@ -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);
}
+42
View File
@@ -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">;
}
+88
View File
@@ -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);
+133
View File
@@ -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})`;
}
+9
View File
@@ -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();
});
+52
View File
@@ -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);
}