experimental: massive hollis changes

This commit is contained in:
zkldi
2022-05-20 00:24:09 +01:00
parent af2fa826a4
commit 2c8b0870fb
94 changed files with 1135 additions and 889 deletions
+18 -9
View File
@@ -50,19 +50,28 @@ export function SendEmail(
return;
}
if (!transporter) {
if (!transporter || !ServerConfig.EMAIL_CONFIG) {
logger.debug(`Stubbed out SendEmail as no EMAIL_CONFIG was set.`);
return;
}
logger.verbose(`Sending email to ${to}.`);
return transporter.sendMail({
from: ServerConfig.EMAIL_CONFIG!.FROM,
to,
subject,
html: htmlContent,
text: textContent,
dkim: ServerConfig.EMAIL_CONFIG?.DKIM,
});
return transporter
.sendMail({
from: ServerConfig.EMAIL_CONFIG.FROM,
to,
subject,
html: htmlContent,
text: textContent,
dkim: ServerConfig.EMAIL_CONFIG.DKIM,
})
.catch((err: unknown) => {
logger.info(`Failed to send email to ${to}.`, {
err,
subject,
textContent,
htmlContent,
});
});
}
+4
View File
@@ -8,6 +8,10 @@ const LOG_LEVEL = ServerConfig.LOGGER_CONFIG.LOG_LEVEL;
t.test("Logger Tests", (t) => {
const logger = CreateLogCtx(__filename);
if (!Transports[0]) {
throw new Error(`No transports were defined? Can't perform logger tests.`)
}
Transports[0].level = "debug"; // lol
logger.debug("Debug Message Test");
@@ -3,10 +3,11 @@
import type { ImportTypeContextMap, ImportTypeDataMap } from "../../import-types/common/types";
import type { ImportTypes } from "tachi-common";
export class ConverterFailure {
export class ConverterFailure extends Error {
message: string;
constructor(message: string) {
super();
this.message = message;
}
}
@@ -32,7 +32,7 @@ export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
}
// (hey, this for loop is backwards!)
for (let i = boundaries.length; i >= 0; i--) {
for (let i = boundaries.length - 1; i >= 0; i--) {
if (percent + Number.EPSILON >= NotNullish(boundaries[i])) {
return grades[i] as Grades[I];
}
@@ -25,66 +25,67 @@ function astrp(v: string) {
t.test("#AssertStrAsPositiveInt", (t) => {
t.strictSame(
astr("---"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- ---.)`),
"Should reject non-int input"
);
t.strictSame(
astr("1.4"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 1.4.)`),
"Should reject float input"
);
t.strictSame(
astr("NaN"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- NaN.)`),
"Should reject NaN"
);
t.strictSame(
astr("-0.3"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- -0.3.)`),
"Should reject negative float input"
);
t.strictSame(
astr("0xFF"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 0xFF.)`),
"Should reject hex input"
);
t.strictSame(
astr("0b11"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 0b11.)`),
"Should reject binary input"
);
t.strictSame(
astr("0o77"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 0o77.)`),
"Should reject oct input"
);
t.strictSame(
astr("--1"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- --1.)`),
"Should reject double neg input"
);
t.strictSame(
astr("12f"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 12f.)`),
"Should reject valid parseInt but invalid Number() input"
);
t.strictSame(
astr(`${Number.MAX_SAFE_INTEGER.toString()}000`),
new InvalidScoreFailure(`err (Not an integer.)`),
// @warn Counterintuitive errmsg here. It is an int!
new InvalidScoreFailure(`err (Not an integer -- ${Number.MAX_SAFE_INTEGER.toString()}000.)`),
"Should reject numbers > max_safe_integer."
);
t.strictSame(
astr("-1"),
new InvalidScoreFailure(`err (Was negative.)`),
new InvalidScoreFailure(`err (Was negative -- -1.)`),
"Should reject negative integer input"
);
@@ -98,78 +99,79 @@ t.test("#AssertStrAsPositiveInt", (t) => {
t.test("#AssertStrAsPositiveNonZeroInt", (t) => {
t.strictSame(
astrp("---"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- ---.)`),
"Should reject non-int input"
);
t.strictSame(
astrp("1.4"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 1.4.)`),
"Should reject float input"
);
t.strictSame(
astrp("NaN"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- NaN.)`),
"Should reject NaN"
);
t.strictSame(
astrp("-0.3"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- -0.3.)`),
"Should reject negative float input"
);
t.strictSame(
astrp("0xFF"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 0xFF.)`),
"Should reject hex input"
);
t.strictSame(
astrp("0b11"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 0b11.)`),
"Should reject binary input"
);
t.strictSame(
astrp("0o77"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 0o77.)`),
"Should reject oct input"
);
t.strictSame(
astrp("--1"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- --1.)`),
"Should reject double neg input"
);
t.strictSame(
astrp("12f"),
new InvalidScoreFailure(`err (Not an integer.)`),
new InvalidScoreFailure(`err (Not an integer -- 12f.)`),
"Should reject valid parseInt but invalid Number() input"
);
t.strictSame(
astrp(`${Number.MAX_SAFE_INTEGER.toString()}000`),
new InvalidScoreFailure(`err (Not an integer.)`),
// @warn Counterintuitive errmsg here. It is an int!
new InvalidScoreFailure(`err (Not an integer -- ${Number.MAX_SAFE_INTEGER.toString()}000.)`),
"Should reject numbers > max_safe_integer."
);
t.strictSame(
astrp("-1"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
new InvalidScoreFailure(`err (Was negative or zero -- -1.)`),
"Should reject negative integer input"
);
1
t.strictSame(
astrp("-0"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
new InvalidScoreFailure(`err (Was negative or zero -- 0.)`),
"Should reject negative 0 input"
);
t.strictSame(
astrp("0"),
new InvalidScoreFailure(`err (Was negative or zero.)`),
new InvalidScoreFailure(`err (Was negative or zero -- 0.)`),
"Should reject 0 input"
);
@@ -2,7 +2,7 @@ import t from "tap";
import db from "external/mongo/db";
import ResetDBState from "test-utils/resets";
import { GetRelevantFolderGoals, GetRelevantGoals, UpdateGoalsForUser, ProcessGoal } from "./goals";
import { GoalDocument, GoalSubscriptionDocument } from "tachi-common";
import { ChartDocument, GoalDocument, GoalSubscriptionDocument, SongDocument } from "tachi-common";
import { CreateFolderChartLookup } from "utils/folder";
import {
GetKTDataJSON,
@@ -93,8 +93,8 @@ t.test("#GetRelevantGoals", (t) => {
// use the real data so we have enough charts loaded for this to test properly.
await db.songs.iidx.remove({});
await db.charts.iidx.remove({});
await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json"));
await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json"));
await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json") as ChartDocument<"iidx:SP"|"iidx:DP">[]);
await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json") as SongDocument<"iidx">[]);
const lotsOfCharts = await db.charts.iidx.find({}, { limit: 20 });
const goals: GoalDocument[] = lotsOfCharts.map((e) => ({
@@ -133,7 +133,7 @@ t.test("#ReprocessOrphan", (t) => {
{
success: true,
type: "ScoreImported",
message: null,
message: "Imported score R7a3a2b04bd4882ec06c198d78297fe3d56561502c7b134c067214bbfdf4f1602.",
content: {
score: {
game: "iidx",
@@ -45,7 +45,7 @@ t.test("#CreateSessions", (t) => {
userID: 1,
importType: "ir/direct-manual",
// name: "adjective1 adjective2 noun1",
sessionID: res[0].sessionID,
sessionID: res[0]?.sessionID,
desc: null,
game: "iidx",
playtype: "SP",
@@ -137,7 +137,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
t.strictSame(sessions[0]?.scoreInfo, [
{
scoreID: "SCORE_ID_1",
isNewScore: true,
@@ -183,7 +183,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
t.strictSame(sessions[0]?.scoreInfo, [
{
scoreID: "SCORE_ID_2",
isNewScore: true,
@@ -246,7 +246,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
t.equal(sessions.length, 2);
t.strictSame(sessions[0].scoreInfo, [
t.strictSame(sessions[0]?.scoreInfo, [
{
scoreID: "SCORE_ID_1",
isNewScore: true,
@@ -260,7 +260,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
isNewScore: true,
},
]);
t.strictSame(sessions[1].scoreInfo, [
t.strictSame(sessions[1]?.scoreInfo, [
{
scoreID: "SCORE_ID_4",
isNewScore: true,
@@ -317,7 +317,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
t.strictSame(sessions[0]?.scoreInfo, [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
@@ -336,7 +336,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
},
]);
t.equal(sessions[0].timeEnded, start + 2000);
t.equal(sessions[0]?.timeEnded, start + 2000);
t.end();
});
@@ -380,7 +380,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
t.strictSame(sessions[0]?.scoreInfo, [
{
scoreID: "EXAMPLE_SCORE_ID",
isNewScore: true,
@@ -399,7 +399,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
},
]);
t.equal(sessions[0].timeStarted, start - 2000);
t.equal(sessions[0]?.timeStarted, start - 2000);
t.end();
});
@@ -427,7 +427,7 @@ t.test("#LoadScoresIntoSessions", (t) => {
t.equal(sessions.length, 1);
t.strictSame(sessions[0].scoreInfo, [
t.strictSame(sessions[0]?.scoreInfo, [
{
scoreID: "TESTING_SCORE_ID",
isNewScore: false,
@@ -12,7 +12,7 @@ const logger = CreateLogCtx(__filename);
t.test("#CalculateGitadoraColour", (t) => {
t.strictSame(
CalculateGitadoraColour("gitadora", "Gita", 1, { skill: 1500 }, logger),
CalculateGitadoraColour("gitadora", "Gita", 1, { skill: 1500 }),
{
colour: GitadoraColours.ORANGE_GRADIENT,
},
@@ -1,9 +1,9 @@
import deepmerge from "deepmerge";
import CreateLogCtx from "lib/logger/logger";
import { BatchManualScore, Game } from "tachi-common";
import { BatchManualScore, ChartDocument, Game, SongDocument } from "tachi-common";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { GetKTDataJSON, Testing511Song, Testing511SPA } from "test-utils/test-data";
import { BMSGazerChart, BMSGazerSong, GetKTDataJSON, Testing511Song, Testing511SPA } from "test-utils/test-data";
import { EscapeStringRegexp } from "utils/misc";
import { InvalidScoreFailure } from "../../../framework/common/converter-failures";
import { ConverterBatchManual, ResolveChartFromSong, ResolveMatchTypeToKTData } from "./converter";
@@ -104,9 +104,6 @@ t.test("#ResolveMatchTypeToKTData", (t) => {
const GAZER17MD5 = "38616b85332037cc12924f2ae2840262";
const GAZER17SHA256 = "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d";
const gazerSong = GetKTDataJSON("./tachi/bms-gazer-song.json");
const gazerChart = GetKTDataJSON("./tachi/bms-gazer-chart.json");
const bmsContext: BatchManualContext = deepmerge(context, { game: "bms", playtype: "7K" });
const resMD5 = await ResolveMatchTypeToKTData(
@@ -121,7 +118,7 @@ t.test("#ResolveMatchTypeToKTData", (t) => {
t.hasStrict(
resMD5,
{ song: gazerSong, chart: gazerChart },
{ song: BMSGazerSong, chart: BMSGazerChart },
"Should return the right song and chart."
);
@@ -137,7 +134,7 @@ t.test("#ResolveMatchTypeToKTData", (t) => {
t.hasStrict(
resSHA256,
{ song: gazerSong, chart: gazerChart },
{ song: BMSGazerSong, chart: BMSGazerChart },
"Should return the right song and chart."
);
@@ -219,7 +219,7 @@ t.test("#ParseEamusementCSV", (t) => {
t.throws(
() => GenericParseEamIIDXCSV(validSPFile, {}, "e-amusement", logger),
new ScoreImportFatalError(400, "Invalid playtype of Nothing given.")
new ScoreImportFatalError(400, "Invalid playtype of undefined given.")
);
let { context } = GenericParseEamIIDXCSV(
@@ -8,7 +8,14 @@ import { SDVXEamusementCSVData } from "./types";
const logger = CreateLogCtx(__filename);
const parsedScore = GetKTDataJSON("./eamusement-sdvx-csv/parsed-data.json");
const parsedScore = {
"title": "ALBIDA Powerless Mix",
"difficulty": "ADVANCED",
"level": "10",
"lamp": "EXCESSIVE COMPLETE",
"score": "9310699",
"exscore": "0"
};
t.test("#ConvertEamSDVXCSV", (t) => {
t.beforeEach(ResetDBState);
@@ -16,12 +16,21 @@ const logger = CreateLogCtx(__filename);
t.test("#ConvertFileMerIIDX", (t) => {
t.beforeEach(ResetDBState);
const MerScore = {
"music_id": 1000,
"play_type": "SINGLE",
"diff_type": "ANOTHER",
"score": 1000,
"miss_count": 21,
"clear_type": "CLEAR",
"update_time": "2021-03-24 07:15:22"
} as const;
function merc(g: Partial<MerScore> = {}) {
return ConvertFileMerIIDX(deepmerge(MerScore, g), {}, "file/mer-iidx", logger);
}
const MerScore = GetKTDataJSON("./mer/merscore.json");
t.test("Valid Conversion", async (t) => {
const res = await ConvertFileMerIIDX(MerScore, {}, "file/mer-iidx", logger);
@@ -17,7 +17,7 @@ t.test("#ParseMerIIDX", (t) => {
function mrfj(obj: unknown) {
return ParseMerIIDX(
MockMulterFile(Buffer.from(JSON.stringify(obj)), "buffer.json"),
MockMulterFile(Buffer.from(JSON.stringify([obj])), "buffer.json"),
{},
logger
);
@@ -75,7 +75,26 @@ t.test("#ParseMerIIDX", (t) => {
t.end();
});
const baseScore = GetKTDataJSON("./mer/base.json");
const baseScore =
{
"music_id": 3007,
"version_id": 3,
"version_name": "3rd style",
"music_name": "Presto",
"play_type": "SINGLE",
"diff_type": "HYPER",
"score": 566,
"score_percent": 56.8273,
"miss_count": 46,
"grade": "B",
"clear_type": "NO PLAY",
"clear_type_id": 0,
"level": 8,
"note": 498,
"play_count": 6,
"update_time": "2019-06-01 19:56:59",
"score_diff_str": "B+12"
};
t.test("Should throw on invalid play_type", (t) => {
t.throws(() => mrfj(deepmerge(baseScore, { play_type: "INVALID" })));
@@ -5,6 +5,7 @@ import ResetDBState from "test-utils/resets";
import {
GetKTDataJSON,
LoadTachiIIDXData,
MockParsedS3Score,
Testing511Song,
Testing511SPA,
} from "test-utils/test-data";
@@ -20,11 +21,12 @@ function cfile(data: S3Score) {
t.test("#ConvertFileS3", (t) => {
t.beforeEach(ResetDBState);
t.beforeEach(() => {
delete BaseS3Score._id; // just incase
// @ts-expect-error hacky just-incase.
delete MockParsedS3Score._id;
});
function mfile(merge: Partial<S3Score>) {
return cfile(deepmerge(BaseS3Score, merge));
return cfile(deepmerge(MockParsedS3Score, merge));
}
const dryScore = {
@@ -51,10 +53,9 @@ t.test("#ConvertFileS3", (t) => {
// timeAchieved: 1287460462000,
};
const BaseS3Score = GetKTDataJSON("./s3/s3score.json");
t.test("Should import a valid S3 score", async (t) => {
const res = await cfile(BaseS3Score);
const res = await cfile(MockParsedS3Score);
t.hasStrict(
res,
@@ -3,7 +3,7 @@ import db from "external/mongo/db";
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { barbScore } from "test-utils/test-data";
import { MockBarbatosScore } from "test-utils/test-data";
import { ConverterIRBarbatos } from "./converter";
import { BarbatosScore } from "./types";
@@ -41,7 +41,7 @@ t.test("#ConverterIRBarbatos", (t) => {
t.test("Should convert a BarbatosScore into a Dry Score", async (t) => {
const res = await ConverterIRBarbatos(
barbScore,
MockBarbatosScore,
{ timeReceived: 10 },
"ir/barbatos",
logger
@@ -86,7 +86,7 @@ t.test("#ConverterIRBarbatos", (t) => {
t.rejects(
() =>
ConverterIRBarbatos(
deepmerge(barbScore, { song_id: 1000 }) as BarbatosScore,
deepmerge(MockBarbatosScore, { song_id: 1000 }) as BarbatosScore,
{ timeReceived: 10 },
"ir/barbatos",
logger
@@ -103,7 +103,7 @@ t.test("#ConverterIRBarbatos", (t) => {
await db.songs.sdvx.remove({ id: 1 }); // force a song-chart desync
t.rejects(
() => ConverterIRBarbatos(barbScore, { timeReceived: 10 }, "ir/barbatos", logger),
() => ConverterIRBarbatos(MockBarbatosScore, { timeReceived: 10 }, "ir/barbatos", logger),
{
message: /Song 1 \(sdvx\) has no parent song/u,
}
@@ -1,7 +1,7 @@
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { barbScore } from "test-utils/test-data";
import { MockBarbatosScore } from "test-utils/test-data";
import { ParseBarbatosSingle } from "./parser";
const logger = CreateLogCtx(__filename);
@@ -10,12 +10,12 @@ t.test("#ParseBarbatosSingle", (t) => {
t.beforeEach(ResetDBState);
t.test("Should return the score as a payload", (t) => {
const res = ParseBarbatosSingle(barbScore as unknown as Record<string, unknown>, logger);
const res = ParseBarbatosSingle(MockBarbatosScore as unknown as Record<string, unknown>, logger);
t.hasStrict(res, {
game: "sdvx",
context: {},
iterable: [barbScore],
iterable: [MockBarbatosScore],
});
t.end();
@@ -1,7 +1,7 @@
import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { GetKTDataJSON } from "test-utils/test-data";
import { FervidexStaticBase, GetKTDataJSON } from "test-utils/test-data";
import { ParseFervidexStatic } from "./parser";
const logger = CreateLogCtx(__filename);
@@ -9,10 +9,8 @@ const logger = CreateLogCtx(__filename);
t.test("#ParseFervidexStatic", (t) => {
t.beforeEach(ResetDBState);
const ferStatic = GetKTDataJSON("./fervidex-static/base.json");
t.test("Should parse static data from body", (t) => {
const res = ParseFervidexStatic(ferStatic, { model: "LDJ:J:B:A:2020092900" }, logger);
const res = ParseFervidexStatic(FervidexStaticBase, { model: "LDJ:J:B:A:2020092900" }, logger);
t.strictSame(res.iterable, [
{
@@ -4,7 +4,7 @@ import CreateLogCtx from "lib/logger/logger";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { GetKTDataJSON, Testing511Song, Testing511SPA } from "test-utils/test-data";
import { InternalFailure } from "../../../framework/common/converter-failures";
import { InternalFailure, InvalidScoreFailure } from "../../../framework/common/converter-failures";
import {
ConverterIRFervidex,
SplitFervidexChartRef,
@@ -81,7 +81,41 @@ t.test("#TachifyRandom", (t) => {
t.end();
});
const baseFervidexScore: FervidexScore = GetKTDataJSON("./fervidex/base.json");
const baseFervidexScore: FervidexScore = {
"bad": 0,
"chart": "spa",
"clear_type": 1,
"combo_break": 6,
"custom": false,
"chart_sha256": "asdfasdf",
"entry_id": 1000,
"ex_score": 68,
"fast": 0,
"gauge": [
100,
50
],
"ghost": [
0,
2
],
"good": 0,
"great": 0,
"max_combo": 34,
"option": {
"gauge": "HARD",
"range": "SUDDEN_PLUS",
"style": "RANDOM"
},
"pacemaker": {
"name": "",
"score": 363,
"type": "PACEMAKER_A"
},
"pgreat": 34,
"poor": 6,
"slow": 0
}
const baseDryScore = {
game: "iidx",
@@ -208,7 +242,7 @@ t.test("#ConverterIRFervidex", (t) => {
"ir/fervidex",
logger
),
{ message: /could not find chart/giu }
/could not find chart/giu
);
t.end();
@@ -224,7 +258,7 @@ t.test("#ConverterIRFervidex", (t) => {
"ir/fervidex",
logger
),
{ message: /Song 1 \(iidx\) has no parent song/giu }
/Song 1 \(iidx\) has no parent song/giu
);
t.end();
@@ -239,7 +273,7 @@ t.test("#ConverterIRFervidex", (t) => {
"ir/fervidex",
logger
),
{ message: /Invalid percent/giu }
/Invalid percent/giu
);
t.end();
@@ -254,7 +288,7 @@ t.test("#ConverterIRFervidex", (t) => {
"ir/fervidex",
logger
),
{ message: /Invalid value of gauge 150/giu }
/Invalid value of gauge 150./giu
);
t.end();
@@ -4,6 +4,7 @@ import {
KTDataNotFoundFailure,
} from "../../../framework/common/converter-failures";
import { GenericGetGradeAndPercent } from "../../../framework/common/score-utils";
import { IsNullishOrEmptyStr } from "utils/misc";
import { FindIIDXChartOnInGameIDVersion, FindIIDXChartWith2DXtraHash } from "utils/queries/charts";
import { FindSongOnID } from "utils/queries/songs";
import type { DryScore } from "../../../framework/common/types";
@@ -147,8 +148,8 @@ export const ConverterIRFervidex: ConverterFunction<FervidexScore, FervidexConte
let chart;
if (data.custom) {
if (!data.chart_sha256) {
if (data.custom === true) {
if (IsNullishOrEmptyStr(data.chart_sha256)) {
throw new InvalidScoreFailure("Score has no chart_sha256 but is a custom?");
}
@@ -182,7 +183,9 @@ export const ConverterIRFervidex: ConverterFunction<FervidexScore, FervidexConte
const gauge = gaugeHistory[gaugeHistory.length - 1];
if (gauge && gauge > 100) {
// If gauge exists and is greater than 100
// must be invalid
if ((gauge ?? 0) > 100) {
throw new InvalidScoreFailure(`Invalid value of gauge ${gauge}.`);
}
+2 -2
View File
@@ -44,12 +44,12 @@ t.test("#SearchUsersRegExp", (t) => {
const res = await SearchUsersRegExp("zkldi");
t.equal(res.length, 1);
t.equal(res[0].usernameLowercase, "test_zkldi");
t.equal(res[0]?.usernameLowercase, "test_zkldi");
const res2 = await SearchUsersRegExp("zk");
t.equal(res2.length, 1);
t.equal(res2[0].usernameLowercase, "test_zkldi");
t.equal(res2[0]?.usernameLowercase, "test_zkldi");
const res3 = await SearchUsersRegExp("zkzdi");
+4 -2
View File
@@ -38,7 +38,9 @@ async function RunOnInit() {
await db["folder-chart-lookup"].findOne().then((r) => {
// If there are no folder chart lookups, initialise them.
if (!r) {
InitaliseFolderChartLookup();
InitaliseFolderChartLookup().catch((err: unknown) => {
logger.error(`Failed to init folder-chart-lookup on first boot?`, { err });
});
}
});
@@ -55,7 +57,7 @@ async function RunOnInit() {
}
}
RunOnInit();
void RunOnInit();
let instance: http.Server | https.Server;
+14 -8
View File
@@ -3,8 +3,9 @@ import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import { TachiConfig } from "lib/setup/config";
import { ALL_PERMISSIONS, UserAuthLevels } from "tachi-common";
import { SplitAuthorizationHeader } from "utils/misc";
import { IsNullishOrEmptyStr, SplitAuthorizationHeader } from "utils/misc";
import type { RequestHandler } from "express";
import type { Session, SessionData } from "express-session";
import type { APIPermissions, APITokenDocument } from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -35,10 +36,15 @@ export const SetRequestPermissions: RequestHandler = CreateSetRequestPermissions
*/
function CreateSetRequestPermissions(errorKeyName: string): RequestHandler {
return async (req, res, next) => {
if (req.session.tachi?.user.id) {
// Types here are wrong. Sometimes req.session is not set.
// As such, we force an assertion.
const maybeSession = req.session as (Partial<SessionData> & Session) | undefined;
if (maybeSession?.tachi?.user.id !== undefined) {
req[SYMBOL_TACHI_API_AUTH] = {
userID: req.session.tachi.user.id,
identifier: `Session-Key ${req.session.tachi.user.id}`,
userID: maybeSession.tachi.user.id,
identifier: `Session-Key ${maybeSession.tachi.user.id}`,
token: null,
permissions: ALL_PERMISSIONS,
fromAPIClient: null,
@@ -50,7 +56,7 @@ function CreateSetRequestPermissions(errorKeyName: string): RequestHandler {
const header = req.header("Authorization");
// if no auth was attempted, default to the guest token.
if (!header) {
if (IsNullishOrEmptyStr(header)) {
req[SYMBOL_TACHI_API_AUTH] = GuestToken;
next();
return;
@@ -124,7 +130,7 @@ export const RequirePermissions =
});
}
if (!req[SYMBOL_TACHI_API_AUTH].userID) {
if (req[SYMBOL_TACHI_API_AUTH].userID === null) {
return res.status(401).json({
success: false,
description: `You are not authorised to perform this action.`,
@@ -134,7 +140,7 @@ export const RequirePermissions =
const missingPerms = [];
for (const perm of perms) {
if (!req[SYMBOL_TACHI_API_AUTH]!.permissions[perm]) {
if (req[SYMBOL_TACHI_API_AUTH].permissions[perm] !== true) {
missingPerms.push(perm);
}
}
@@ -185,7 +191,7 @@ export const RequireNotGuest: RequestHandler = CreateRequireNotGuest("descriptio
export const FervidexStyleRequireNotGuest: RequestHandler = CreateRequireNotGuest("error");
export const RejectIfBanned: RequestHandler = async (req, res, next) => {
if (req[SYMBOL_TACHI_API_AUTH].userID) {
if (req[SYMBOL_TACHI_API_AUTH].userID !== null) {
const isBanned = await db.users.findOne({
id: req[SYMBOL_TACHI_API_AUTH].userID!,
authLevel: UserAuthLevels.BANNED,
@@ -8,7 +8,8 @@ import type { integer } from "tachi-common";
const defaultLogger = CreateLogCtx(__filename);
export const DefaultMulterUpload = multer({ limits: { fileSize: 1024 * 1024 * 16 } }); // 16MB
// 16MB
export const DefaultMulterUpload = multer({ limits: { fileSize: 1024 * 1024 * 16 } });
export const CreateMulterSingleUploadMiddleware = (
fieldName: string,
@@ -44,6 +45,10 @@ export const CreateMulterSingleUploadMiddleware = (
});
}
// CRITICALLY IMPORTANT LINE OF CODE
// THINGS DEALING WITH FILE UPLOADS **DO NOT** MOUNT SAFE-BODY OTHERWISE.
req.safeBody = req.body as Record<string, unknown>;
next();
});
};
@@ -17,16 +17,9 @@ const ResJsonInteceptor = (res: Response, json: Response["json"]) => (content: u
};
export const RequestLoggerMiddleware: RequestHandler = (req, res, next) => {
// I'm not really a fan of this style of omission - but it works.
// we **KNOW** for certain that there are only two endpoints where a password is
// sent to us - /register and /auth.
// and both of those use `password` as a key.
// still, i don't like it.
const safeBody: Record<string, unknown> = {};
for (const [k, v] of Object.entries(req.body)) {
for (const [k, v] of Object.entries(req.safeBody)) {
// Keys that start with ! are private information,
// and should not ever be logged.
if (k.startsWith("!")) {
+47 -14
View File
@@ -11,17 +11,18 @@ import prValidate from "server/middleware/prudence-validate";
import { UserAuthLevels } from "tachi-common";
import { RecalcAllScores, UpdateAllPBs } from "utils/calculations/recalc-scores";
import { RecalcSessions } from "utils/calculations/recalc-sessions";
import { IsValidPlaytype } from "utils/misc";
import DestroyUserGamePlaytypeData from "utils/reset-state/destroy-ugpt";
import { GetUserWithID } from "utils/user";
import type { RequestHandler } from "express";
import type { Game } from "tachi-common";
import type { Game, integer, Playtype } from "tachi-common";
const logger = CreateLogCtx(__filename);
const router: Router = Router({ mergeParams: true });
const RequireAdminLevel: RequestHandler = async (req, res, next) => {
if (!req[SYMBOL_TACHI_API_AUTH].userID) {
if (req[SYMBOL_TACHI_API_AUTH].userID === null) {
return res.status(401).json({
success: false,
description: `You are not authenticated.`,
@@ -75,20 +76,26 @@ router.post(
noReset: p.optional("boolean"),
}),
(req, res) => {
const body = req.safeBody as {
logLevel: "crit" | "debug" | "error" | "info" | "severe" | "verbose" | "warn";
duration?: integer;
noReset?: boolean;
};
const logLevel = GetLogLevel();
ChangeRootLogLevel(req.body.logLevel);
ChangeRootLogLevel(body.logLevel);
const duration = req.body.duration ?? 60;
const duration = body.duration ?? 60;
if (currentLogLevelTimer) {
logger.verbose(`Removing last timer to reset log level to ${LOG_LEVEL}.`);
clearTimeout(currentLogLevelTimer);
}
logger.info(`Log level has been changed to ${req.body.level}.`);
logger.info(`Log level has been changed to ${body.logLevel}.`);
if (!req.body.noReset) {
if (body.noReset !== true) {
logger.info(`This will reset to "${LOG_LEVEL}" level in ${duration} minutes.`);
currentLogLevelTimer = setTimeout(() => {
@@ -100,7 +107,7 @@ router.post(
return res.status(200).json({
success: true,
description: `Changed log level from ${logLevel} to ${req.body.logLevel}.`,
description: `Changed log level from ${logLevel} to ${body.logLevel}.`,
body: {},
});
}
@@ -121,7 +128,12 @@ router.post(
filter: "*object",
}),
async (req, res) => {
await UpdateAllPBs(req.body.userIDs, req.body.filter);
const body = req.safeBody as {
userIDs?: Array<integer>;
filter?: object;
};
await UpdateAllPBs(body.userIDs, body.filter);
return res.status(200).json({
success: true,
@@ -139,7 +151,9 @@ router.post(
* @name POST /api/v1/admin/delete-score
*/
router.post("/delete-score", prValidate({ scoreID: "string" }), async (req, res) => {
const score = await db.scores.findOne({ scoreID: req.body.scoreID });
const body = req.safeBody as { scoreID: string };
const score = await db.scores.findOne({ scoreID: body.scoreID });
if (!score) {
return res.status(404).json({
@@ -171,14 +185,29 @@ router.post(
prValidate({
userID: p.isInteger,
game: p.isIn(TachiConfig.GAMES),
playtype: "string", // lazy
playtype: (self, parent) => {
if (typeof self !== "string") {
return "Expected a string for a playtype.";
}
if (!IsValidPlaytype(parent.game as Game, self)) {
return `Invalid playtype of ${self} for game ${parent.game as Game}.`;
}
return true;
},
}),
async (req, res) => {
const { userID, game, playtype } = req.body;
const { userID, game, playtype } = req.safeBody as {
userID: integer;
game: Game;
playtype: Playtype;
};
const ugpt = await db["game-stats"].findOne({
userID,
game,
playtype,
});
@@ -211,8 +240,12 @@ router.post(
"/destroy-chart",
prValidate({ chartID: "string", game: p.isIn(TachiConfig.GAMES) }),
async (req, res) => {
const game: Game = req.body.game;
const chartID: string = req.body.chartID;
const body = req.safeBody as {
game: Game;
chartID: string;
};
const { game, chartID } = body;
const scores = await db.scores.find({
chartID,
@@ -242,7 +275,7 @@ router.post(
* @name POST /api/v1/admin/recalc
*/
router.post("/recalc", async (req, res) => {
const filter = req.body ?? {};
const filter = req.safeBody;
await RecalcAllScores(filter);
+72 -41
View File
@@ -29,6 +29,7 @@ import {
GetUserCaseInsensitive,
GetUserPrivateInfo,
GetUserWithID,
GetUserWithIDGuaranteed,
} from "utils/user";
import type { integer } from "tachi-common";
@@ -60,17 +61,23 @@ router.post(
"verbose"
),
async (req, res) => {
if (req.session.tachi?.user.id) {
if (req.session.tachi?.user.id !== undefined) {
// Dual logins should destroy the users session and recreate it.
req.session.tachi = undefined;
}
logger.verbose(`Received login request with username ${req.body.username} (${req.ip})`);
const body = req.safeBody as {
username: string;
"!password": string;
captcha: string;
};
logger.verbose(`Received login request with username ${body.username} (${req.ip})`);
/* istanbul ignore next */
if (Environment.nodeEnv === "production" || Environment.nodeEnv === "staging") {
logger.verbose("Validating captcha...");
const validCaptcha = await ValidateCaptcha(req.body.captcha, req.socket.remoteAddress);
const validCaptcha = await ValidateCaptcha(body.captcha, req.socket.remoteAddress);
if (!validCaptcha) {
logger.verbose("Captcha failed.");
@@ -85,10 +92,10 @@ router.post(
logger.warn("Skipped captcha check because not in production.");
}
const requestedUser = await GetUserCaseInsensitive(req.body.username);
const requestedUser = await GetUserCaseInsensitive(body.username);
if (!requestedUser) {
logger.verbose(`Invalid username for login ${req.body.username}.`);
logger.verbose(`Invalid username for login ${body.username}.`);
return res.status(404).json({
success: false,
description: `This user does not exist.`,
@@ -111,7 +118,7 @@ router.post(
});
}
const passwordMatch = await PasswordCompare(req.body["!password"], privateInfo.password);
const passwordMatch = await PasswordCompare(body["!password"], privateInfo.password);
if (!passwordMatch) {
logger.verbose("Invalid password provided.");
@@ -178,12 +185,27 @@ router.post(
"verbose"
),
async (req, res) => {
logger.verbose(`received register request with username ${req.body.username} (${req.ip})`);
const body = req.safeBody as {
username: string;
"!password": string;
email: string;
inviteCode?: string;
captcha: string;
};
if (body.inviteCode === undefined && ServerConfig.INVITE_CODE_CONFIG) {
return res.status(400).json({
success: false,
description: `No invite code given, yet the server uses invites.`,
});
}
logger.verbose(`received register request with username ${body.username} (${req.ip})`);
/* istanbul ignore next */
if (Environment.nodeEnv === "production" || Environment.nodeEnv === "staging") {
logger.verbose("Validating captcha...");
const validCaptcha = await ValidateCaptcha(req.body.captcha, req.socket.remoteAddress);
const validCaptcha = await ValidateCaptcha(body.captcha, req.socket.remoteAddress);
if (!validCaptcha) {
logger.verbose("Captcha failed.");
@@ -198,17 +220,17 @@ router.post(
logger.warn("Skipped captcha check because not in production.");
}
const existingUser = await GetUserCaseInsensitive(req.body.username);
const existingUser = await GetUserCaseInsensitive(body.username);
if (existingUser) {
logger.verbose(`Invalid username ${req.body.username}, already in use.`);
logger.verbose(`Invalid username ${body.username}, already in use.`);
return res.status(409).json({
success: false,
description: "This username is already in use.",
});
}
const existingEmail = await CheckIfEmailInUse(req.body.email);
const existingEmail = await CheckIfEmailInUse(body.email);
if (existingEmail) {
logger.info(`User attempted to sign up with email that was already in use.`);
@@ -226,7 +248,7 @@ router.post(
if (ServerConfig.INVITE_CODE_CONFIG) {
const inviteCodeDoc = await db.invites.findOneAndUpdate(
{
code: req.body.inviteCode,
code: body.inviteCode,
consumed: false,
},
{
@@ -239,7 +261,7 @@ router.post(
);
if (!inviteCodeDoc) {
logger.info(`Invalid invite code given: ${req.body.inviteCode}.`);
logger.info(`Invalid invite code given: ${body.inviteCode}.`);
return res.status(401).json({
success: false,
description: `This invite code is not valid.`,
@@ -252,52 +274,48 @@ router.post(
// if we get to this point, We're good to create the user.
const { newUser, newSettings } = await AddNewUser(
req.body.username,
req.body["!password"],
req.body.email,
body.username,
body["!password"],
body.email,
userID
);
if (!newUser) {
throw new Error("AddNewUser failed to create a user.");
}
hasInsertedUserID = newUser.id;
// re-fetch the user like this so we guaranteeably omit the private fields.
const user = await GetUserWithID(newUser.id);
const user = await GetUserWithIDGuaranteed(newUser.id);
MountAuthCookie(req, user!, newSettings);
MountAuthCookie(req, user, newSettings);
const resetEmailCode = Random20Hex();
await db["verify-email-codes"].insert({
code: resetEmailCode,
userID,
email: req.body.email,
email: body.email,
});
const { text, html } = EmailFormatVerifyEmail(user!.username, resetEmailCode);
const { text, html } = EmailFormatVerifyEmail(user.username, resetEmailCode);
SendEmail(req.body.email, "Email Verification", html, text);
void SendEmail(body.email, "Email Verification", html, text);
return res.status(200).json({
success: true,
description: `Successfully created account ${req.body.username}!`,
description: `Successfully created account ${body.username}!`,
body: user,
});
} catch (err) {
logger.error(`Bailed on user creation ${req.body.username}.`, { err });
logger.error(`Bailed on user creation ${body.username}.`, { err });
if (ServerConfig.INVITE_CODE_CONFIG) {
await ReinstateInvite(req.body.inviteCode);
if (ServerConfig.INVITE_CODE_CONFIG && body.inviteCode !== undefined) {
await ReinstateInvite(body.inviteCode);
}
if (hasInsertedUserID !== null) {
logger.warn(
`Removing user ${req.body.username} (#${hasInsertedUserID}), as their document was created, but creation still failed.`
`Removing user ${body.username} (#${hasInsertedUserID}), as their document was created, but creation still failed.`
);
await db.users.remove({ username: req.body.username });
await db.users.remove({ username: body.username });
await db["user-settings"].remove({ userID: hasInsertedUserID });
await db["user-private-information"].remove({ userID: hasInsertedUserID });
}
@@ -326,8 +344,12 @@ router.post(
code: "string",
}),
async (req, res) => {
const body = req.safeBody as {
code: string;
};
const code = await db["verify-email-codes"].findOne({
code: req.body.code,
code: body.code,
});
if (!code) {
@@ -338,7 +360,7 @@ router.post(
}
await db["verify-email-codes"].remove({
code: req.body.code,
code: body.code,
});
return res.status(200).json({
@@ -387,7 +409,7 @@ router.post("/resend-verify-email", HyperAggressiveRateLimitMiddleware, async (r
const { text, html } = EmailFormatVerifyEmail(user.username, verifyInfo.code);
SendEmail(verifyInfo.email, "Email Verification", html, text);
void SendEmail(verifyInfo.email, "Email Verification", html, text);
});
/**
@@ -395,7 +417,7 @@ router.post("/resend-verify-email", HyperAggressiveRateLimitMiddleware, async (r
* @name POST /api/v1/auth/logout
*/
router.post("/logout", (req, res) => {
if (!req.session.tachi?.user.id) {
if (req.session.tachi?.user.id === undefined) {
return res.status(409).json({
success: false,
description: `You are not logged in.`,
@@ -431,7 +453,11 @@ router.post(
});
}
logger.debug(`received password reset request for ${req.body.email}.`);
const body = req.safeBody as {
email: string;
};
logger.debug(`received password reset request for ${body.email}.`);
// For timing attack and infosec reasons, we can't do anything but **immediately** return here.
res.status(202).json({
@@ -441,7 +467,7 @@ router.post(
});
const userPrivateInfo = await db["user-private-information"].findOne({
email: req.body.email,
email: body.email,
});
if (userPrivateInfo) {
@@ -466,10 +492,10 @@ router.post(
const { html, text } = EmailFormatResetPassword(user.username, code, req.ip);
SendEmail(userPrivateInfo.email, "Reset Password", html, text);
void SendEmail(userPrivateInfo.email, "Reset Password", html, text);
} else {
logger.info(
`Silently rejected password reset request for ${req.body.email}, as no user has this email.`
`Silently rejected password reset request for ${body.email}, as no user has this email.`
);
}
}
@@ -492,8 +518,13 @@ router.post(
"!password": ValidatePassword,
}),
async (req, res) => {
const body = req.safeBody as {
code: string;
"!password": string;
};
const code = await db["password-reset-codes"].findOneAndDelete({
code: req.body.code,
code: body.code,
});
if (!code) {
@@ -503,7 +534,7 @@ router.post(
});
}
const encryptedPassword = await HashPassword(req.body["!password"]);
const encryptedPassword = await HashPassword(body["!password"]);
await db["user-private-information"].update(
{
@@ -12,6 +12,7 @@ t.test("#GetClientFromID", (t) => {
params: {
clientID: "OAUTH2_CLIENT_ID",
},
[SYMBOL_TACHI_DATA]: {}
});
t.strictSame(
@@ -27,7 +28,7 @@ t.test("#GetClientFromID", (t) => {
apiKeyTemplate: null,
apiKeyFilename: null,
},
"Should assign clientDoc with secret ommitted."
"Should assign clientDoc with secret omitted."
);
t.end();
@@ -38,6 +39,7 @@ t.test("#GetClientFromID", (t) => {
params: {
clientID: "NONSENSE",
},
[SYMBOL_TACHI_DATA]: {}
});
t.equal(res.statusCode, 404);
@@ -58,11 +60,12 @@ t.test("#RequireOwnershipOfClient", (t) => {
t.test("Should return 401 if the user has no authentication.", async (t) => {
const { res } = await expMiddlewareMock(RequireOwnershipOfClient, {
body: {
safeBody: {
__terribleHackOauth2ClientDoc: {
author: 1,
},
},
[SYMBOL_TACHI_DATA]: {},
session: {},
});
@@ -73,7 +76,7 @@ t.test("#RequireOwnershipOfClient", (t) => {
t.test("Should return 403 if the user does not own this client.", async (t) => {
const { res } = await expMiddlewareMock(RequireOwnershipOfClient, {
body: {
safeBody: {
__terribleHackOauth2ClientDoc: {
author: 1,
},
@@ -85,6 +88,7 @@ t.test("#RequireOwnershipOfClient", (t) => {
},
},
},
[SYMBOL_TACHI_DATA]: {},
});
t.equal(res.statusCode, 403);
@@ -94,7 +98,7 @@ t.test("#RequireOwnershipOfClient", (t) => {
t.test("Should continue if this is their session.", async (t) => {
const { res } = await expMiddlewareMock(RequireOwnershipOfClient, {
body: {
safeBody: {
__terribleHackOauth2ClientDoc: {
author: 1,
},
@@ -106,6 +110,7 @@ t.test("#RequireOwnershipOfClient", (t) => {
},
},
},
[SYMBOL_TACHI_DATA]: {},
});
t.equal(res.statusCode, 200);
@@ -1,5 +1,4 @@
import db from "external/mongo/db";
import { SYMBOL_TACHI_DATA } from "lib/constants/tachi";
import { Environment } from "lib/setup/config";
import { AssignToReqTachiData, GetTachiData } from "utils/req-tachi-data";
import type { RequestHandler } from "express";
@@ -37,10 +36,13 @@ export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => {
// request. To hack around this for testing, we perform this hack.
// There's an open issue for this here: https://github.com/i-like-robots/express-request-mock/issues/19
/* istanbul ignore next */
if (Environment.nodeEnv === "test" && req.body.__terribleHackOauth2ClientDoc) {
if (
Environment.nodeEnv === "test" &&
(req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument | undefined)
) {
// obviously a glaring hack and security flaw - this only applies
// in testing.
client = req.body.__terribleHackOauth2ClientDoc;
client = req.safeBody.__terribleHackOauth2ClientDoc as TachiAPIClientDocument;
} else {
client = GetTachiData(req, "apiClientDoc");
}
@@ -1,7 +1,6 @@
import { GetClientFromID, RequireOwnershipOfClient } from "./middleware";
import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TACHI_DATA } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import { ServerConfig } from "lib/setup/config";
import p from "prudence";
@@ -90,6 +89,15 @@ router.post(
});
}
const body = req.safeBody as {
name: string;
redirectUri: string | null;
webhookUri: string | null;
apiKeyTemplate: string | null;
apiKeyFilename: string | null;
permissions: Array<APIPermissions>;
};
const existingClients = await db["api-clients"].find({
author: req.session.tachi.user.id,
});
@@ -105,7 +113,7 @@ router.post(
});
}
const permissions = DedupeArr<APIPermissions>(req.body.permissions);
const permissions = DedupeArr<APIPermissions>(body.permissions);
if (permissions.length === 0) {
return res.status(400).json({
@@ -114,14 +122,14 @@ router.post(
});
}
if (req.body.redirectUri !== null && !IsValidURL(req.body.redirectUri)) {
if (body.redirectUri !== null && !IsValidURL(body.redirectUri)) {
return res.status(400).json({
success: false,
description: `Invalid Redirect URL.`,
});
}
if (req.body.webhookUri !== null && !IsValidURL(req.body.webhookUri)) {
if (body.webhookUri !== null && !IsValidURL(body.webhookUri)) {
return res.status(400).json({
success: false,
description: `Invalid Webhook URL.`,
@@ -135,19 +143,19 @@ router.post(
clientID,
clientSecret,
requestedPermissions: permissions,
name: req.body.name,
name: body.name,
author: req.session.tachi.user.id,
redirectUri: req.body.redirectUri,
webhookUri: req.body.webhookUri ?? null,
apiKeyFilename: req.body.apiKeyFilename ?? null,
apiKeyTemplate: req.body.apiKeyTemplate ?? null,
redirectUri: body.redirectUri,
webhookUri: body.webhookUri ?? null,
apiKeyFilename: body.apiKeyFilename ?? null,
apiKeyTemplate: body.apiKeyTemplate ?? null,
};
await db["api-clients"].insert(clientDoc);
logger.info(
`User ${FormatUserDoc(req.session.tachi.user)} created a new API Client ${
req.body.name
body.name
} (${clientID}).`
);
@@ -232,11 +240,20 @@ router.patch(
}),
}),
async (req, res) => {
const body = req.safeBody as {
name?: string;
redirectUri?: string | null;
webhookUri?: string | null;
apiKeyTemplate?: string | null;
apiKeyFilename?: string | null;
permissions?: Array<APIPermissions>;
};
const client = GetTachiData(req, "apiClientDoc");
DeleteUndefinedProps(req.body);
DeleteUndefinedProps(req.safeBody);
if (Object.keys(req.body).length === 0) {
if (Object.keys(req.safeBody).length === 0) {
return res.status(400).json({
success: false,
description: `No changes to make.`,
@@ -248,12 +265,12 @@ router.patch(
clientID: client.clientID,
},
{
$set: req.body,
$set: req.safeBody,
}
);
logger.info(
`API Client ${client.name} (${client.clientID}) has been renamed to ${req.body.name}.`
`API Client ${client.name} (${client.clientID}) has been renamed to ${body.name}.`
);
return res.status(200).json({
@@ -17,7 +17,7 @@ const LoadLazySampleData = async () => {
await db.goals.insert(IIDXSPMilestoneGoals);
await db["goal-subs"].insert([
...IIDXSPMilestoneGoalSubs,
dm(IIDXSPMilestoneGoalSubs[0], {
dm(IIDXSPMilestoneGoalSubs[0]!, {
userID: 2,
}),
] as GoalSubscriptionDocument[]);
@@ -2,6 +2,7 @@ import dm from "deepmerge";
import db from "external/mongo/db";
import { GoalSubscriptionDocument } from "tachi-common";
import t from "tap";
import { mkFakeGoal, mkFakeGoalSub } from "test-utils/misc";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
import { HC511UserGoal } from "test-utils/test-data";
@@ -9,18 +10,19 @@ import { HC511UserGoal } from "test-utils/test-data";
t.test("GET /api/v1/games/:game/:playtype/targets/recently-achieved", (t) => {
t.beforeEach(ResetDBState);
// mutate
function m(partial: Partial<GoalSubscriptionDocument>): GoalSubscriptionDocument {
return dm(HC511UserGoal, partial);
}
t.test("Should return some recently achieved goals.", async (t) => {
await db.goals.insert([
mkFakeGoal({ goalID: "achieved" }),
mkFakeGoal({ goalID: "achieved_more_recently" }),
mkFakeGoal({ goalID: "achieved_instantly" }),
])
await db["goal-subs"].insert([
// not achieved
HC511UserGoal,
m({ goalID: "achieved", achieved: true, timeAchieved: 1000 }),
m({ goalID: "achieved_more_recently", achieved: true, timeAchieved: 2000 }),
m({
mkFakeGoalSub({ goalID: "achieved", achieved: true, timeAchieved: 1000 }),
mkFakeGoalSub({ goalID: "achieved_more_recently", achieved: true, timeAchieved: 2000 }),
mkFakeGoalSub({
goalID: "achieved_instantly",
achieved: true,
timeAchieved: 1000,
@@ -48,25 +50,27 @@ t.test("GET /api/v1/games/:game/:playtype/targets/recently-achieved", (t) => {
t.test("GET /api/v1/games/:game/:playtype/targets/recently-raised", (t) => {
t.beforeEach(ResetDBState);
// mutate
function m(partial: Partial<GoalSubscriptionDocument>): GoalSubscriptionDocument {
return dm(HC511UserGoal, partial);
}
t.test("Should return some recently interacted goals.", async (t) => {
await db.goals.insert([
mkFakeGoal({ goalID: "interacted" }),
mkFakeGoal({ goalID: "interacted_more_recently" }),
mkFakeGoal({ goalID: "achieved" }),
mkFakeGoal({ goalID: "achieved_instantly" }),
]);
await db["goal-subs"].insert([
// not achieved
HC511UserGoal,
m({ goalID: "interacted", achieved: false, lastInteraction: 1000 }),
mkFakeGoalSub({ goalID: "interacted", achieved: false, lastInteraction: 1000 }),
// happened more recently
m({ goalID: "interacted_more_recently", achieved: false, lastInteraction: 2000 }),
mkFakeGoalSub({ goalID: "interacted_more_recently", achieved: false, lastInteraction: 2000 }),
// shouldnt be included -- just recently-raised.
m({
mkFakeGoalSub({
goalID: "achieved",
achieved: true,
lastInteraction: 1000,
}),
m({
mkFakeGoalSub({
goalID: "achieved_instantly",
achieved: true,
lastInteraction: 1000,
@@ -58,7 +58,7 @@ router.post(
});
}
const importType = req.body.importType as FileUploadImportTypes;
const importType = req.safeBody.importType as FileUploadImportTypes;
const userIntent = !!req.header("X-User-Intent");
@@ -70,11 +70,11 @@ router.post(
userID: req[SYMBOL_TACHI_API_AUTH].userID!,
userIntent,
importType,
parserArguments: [req.file, req.body],
parserArguments: [req.file, req.safeBody],
};
// Fire the score import, but make no guarantees about its state.
MakeScoreImport<FileUploadImportTypes>(job);
void MakeScoreImport<FileUploadImportTypes>(job);
return res.status(202).json({
success: true,
@@ -92,7 +92,7 @@ router.post(
req[SYMBOL_TACHI_API_AUTH].userID!,
userIntent,
importType,
[req.file, req.body]
[req.file, req.safeBody]
);
return res.status(importResponse.statusCode).json(importResponse.body);
@@ -114,7 +114,7 @@ router.post(
{ allowExcessKeys: true }
),
async (req, res) => {
const importType = req.body.importType as APIImportTypes;
const importType = req.safeBody.importType as APIImportTypes;
const importID = Random20Hex();
@@ -31,8 +31,16 @@ router.post(
code: "string",
}),
async (req, res) => {
const body = req.safeBody as {
client_id: string;
client_secret: string;
grant_type: "authorization_code";
redirect_uri: string;
code: string;
};
const client = await db["api-clients"].findOne({
clientID: req.body.client_id,
clientID: body.client_id,
});
if (!client) {
@@ -42,7 +50,7 @@ router.post(
});
}
if (client.clientSecret !== req.body.client_secret) {
if (client.clientSecret !== body.client_secret) {
return res.status(403).json({
success: false,
description: `Invalid secret.`,
@@ -51,14 +59,14 @@ router.post(
// I honest to god have no idea what the point of this check is
// but it's part of the oauth spec.
if (client.redirectUri !== req.body.redirect_uri) {
if (client.redirectUri !== body.redirect_uri) {
return res.status(400).json({
success: false,
description: `This redirect_uri does not match with your registered client redirect_uri ${client.redirectUri}.`,
});
}
const codeDoc = await db["oauth2-auth-codes"].findOne({ code: req.body.code });
const codeDoc = await db["oauth2-auth-codes"].findOne({ code: body.code });
if (!codeDoc) {
return res.status(404).json({
@@ -68,7 +76,7 @@ router.post(
}
// don't let people auth with the same code multiple times.
await db["oauth2-auth-codes"].remove({ code: req.body.code });
await db["oauth2-auth-codes"].remove({ code: body.code });
const apiDoc = {
userID: codeDoc.userID,
@@ -1,7 +1,6 @@
import { GetScoreFromParam, RequireOwnershipOfScoreOrAdmin } from "./middleware";
import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TACHI_DATA } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import { DeleteScore } from "lib/score-mutation/delete-scores";
import p from "prudence";
@@ -26,7 +25,7 @@ router.use(GetScoreFromParam);
router.get("/", async (req, res) => {
const score = GetTachiData(req, "scoreDoc");
if (req.query.getRelated) {
if (req.query.getRelated !== undefined) {
const [user, chart, song] = await Promise.all([
GetUserWithID(score.userID),
db.charts[score.game].findOne({ chartID: score.chartID }),
@@ -88,16 +87,21 @@ router.patch(
highlight: "*boolean",
}),
async (req, res) => {
const body = req.safeBody as {
comment?: string | null;
highlight?: boolean;
};
const score = GetTachiData(req, "scoreDoc");
const modifyOption: ModifiableScoreProps = {};
if (req.body.comment !== undefined) {
modifyOption.comment = req.body.comment;
if (body.comment !== undefined) {
modifyOption.comment = body.comment;
}
if (req.body.highlight !== undefined) {
modifyOption.highlight = req.body.highlight;
if (body.highlight !== undefined) {
modifyOption.highlight = body.highlight;
}
if (Object.keys(modifyOption).length === 0) {
@@ -145,11 +149,16 @@ router.patch(
router.delete(
"/",
RequireOwnershipOfScoreOrAdmin,
prValidate({ blacklist: "*boolean" }),
RequirePermissions("delete_score"),
async (req, res) => {
const body = req.safeBody as {
blacklist?: boolean;
};
const score = GetTachiData(req, "scoreDoc");
await DeleteScore(score, !!req.body.blacklist);
await DeleteScore(score, body.blacklist);
return res.status(200).json({
success: true,
@@ -87,16 +87,16 @@ router.patch(
const updateExp: ModifiableSessionProps = {};
if (req.body.name) {
updateExp.name = req.body.name as string;
if (req.safeBody.name) {
updateExp.name = req.safeBody.name as string;
}
if (req.body.desc) {
updateExp.desc = req.body.desc as string;
if (req.safeBody.desc) {
updateExp.desc = req.safeBody.desc as string;
}
if (typeof req.body.highlight === "boolean") {
updateExp.highlight = req.body.highlight as boolean;
if (typeof req.safeBody.highlight === "boolean") {
updateExp.highlight = req.safeBody.highlight;
}
if (Object.keys(updateExp).length === 0) {
@@ -45,8 +45,8 @@ router.get("/", (req, res) => {
router.post("/", (req, res) => {
let echo;
if (req.body.echo && typeof req.body.echo === "string") {
echo = req.body.echo;
if (req.safeBody.echo && typeof req.safeBody.echo === "string") {
echo = req.safeBody.echo;
}
return res.status(200).json({
@@ -1,7 +1,6 @@
import { RequireSelfRequestFromUser } from "../middleware";
import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TACHI_DATA } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import p from "prudence";
import prValidate from "server/middleware/prudence-validate";
@@ -55,7 +54,13 @@ router.post(
clientID: "*string",
}),
async (req, res) => {
if (req.body.clientID && req.body.permissions) {
const body = req.safeBody as {
permissions?: Array<APIPermissions>;
identifier?: string;
clientID?: string;
};
if (body.clientID !== undefined && body.permissions) {
return res.status(400).json({
success: false,
description: `Cannot use ClientID creation and permissions creation at the same time!`,
@@ -69,10 +74,10 @@ router.post(
let identifier: string;
let fromAPIClient = null;
if (req.body.clientID) {
if (body.clientID !== undefined) {
const client = await db["api-clients"].findOne(
{
clientID: req.body.clientID,
clientID: body.clientID,
},
{
projection: {
@@ -108,9 +113,9 @@ router.post(
logger.info(
`Creating API Key for ${FormatUserDoc(user)} from ${client.name} specification.`
);
} else if (req.body.permissions) {
permissions = req.body.permissions;
identifier = req.body.identifier ?? "Custom Token";
} else if (body.permissions) {
permissions = body.permissions;
identifier = body.identifier ?? "Custom Token";
logger.info(
`Creating API Key for ${FormatUserDoc(user)} with ${permissions.join(", ")}.`
@@ -144,39 +149,4 @@ router.post(
}
);
/**
* Delete this token.
*
* @name DELETE /api/v1/users/:userID/api-token/:token
*/
router.delete("/:token", async (req, res) => {
const user = GetTachiData(req, "requestedUser");
logger.info(
`received request from ${FormatUserDoc(user)} to delete token ${req.params.token}.`
);
const token = await db["api-tokens"].findOne({
token: req.params.token,
userID: user.id,
});
if (!token) {
return res.status(404).json({
success: false,
description: `This key does not exist.`,
});
}
await db["api-tokens"].remove({ token: req.params.token });
logger.info(`Deleted ${req.params.token}, which belonged to ${FormatUserDoc(user)}.`);
return res.status(200).json({
success: true,
description: `Removed Token.`,
body: {},
});
});
export default router;
@@ -1,6 +1,6 @@
import deepmerge from "deepmerge";
import db from "external/mongo/db";
import { FolderDocument, ScoreDocument } from "tachi-common";
import { ChartDocument, FolderDocument, ScoreDocument, SongDocument } from "tachi-common";
import t from "tap";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
@@ -65,8 +65,8 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/folders/:folderID/timeli
await db.songs.iidx.remove({});
await db.charts.iidx.remove({});
await db["folder-chart-lookup"].remove({});
await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json"));
await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json"));
await db.songs.iidx.insert(GetKTDataJSON("./tachi/tachi-songs-iidx.json") as Array<SongDocument<"iidx">>);
await db.charts.iidx.insert(GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array<ChartDocument<"iidx:DP"|"iidx:SP">>);
await CreateFolderChartLookup(folder, true);
@@ -1,5 +1,5 @@
import db from "external/mongo/db";
import { PBScoreDocument } from "tachi-common";
import { ChartDocument, PBScoreDocument } from "tachi-common";
import t from "tap";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
@@ -89,16 +89,22 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs", (t) => {
t.test("Should search a user's personal bests.", async (t) => {
const mockPBs: PBScoreDocument[] = [];
const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json");
const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array<ChartDocument>;
for (let i = 0; i < 200; i++) {
let chart = charts[i];
if (!chart) {
return t.fail(`Not enough charts in tachi-charts-iidx.json to mock pb data? Failed at index ${i}.`);
}
mockPBs.push({
userID: 1,
game: "iidx",
playtype: "SP",
isPrimary: true,
chartID: charts[i].chartID,
songID: charts[i].songID,
chartID: chart.chartID,
songID: chart.songID,
calculatedData: {
ktLampRating: i,
},
@@ -43,7 +43,11 @@ router.put(
rivalIDs: [p.isPositiveNonZeroInteger],
}),
async (req, res) => {
const rivalIDs: Array<integer> = req.body.rivalIDs;
const body = req.safeBody as {
rivalIDs: Array<integer>;
};
const rivalIDs = body.rivalIDs;
const { user, game, playtype } = GetUGPT(req);
const result = await SetRivals(user.id, game, playtype, rivalIDs);
@@ -1,5 +1,5 @@
import db from "external/mongo/db";
import { ScoreDocument } from "tachi-common";
import { ChartDocument, ScoreDocument } from "tachi-common";
import t from "tap";
import { mkFakeScoreIIDXSP, mkFakeScoreSDVX } from "test-utils/misc";
import mockApi from "test-utils/mock-api";
@@ -88,17 +88,23 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores", (t) => {
t.test("Should search a user's scores.", async (t) => {
const mockScores: ScoreDocument[] = [];
const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json");
const charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array<ChartDocument<"iidx:DP"|"iidx:SP">>;
for (let i = 0; i < 200; i++) {
const chart = charts[i];
if (!chart) {
return t.fail(`tachi-charts-iidx.json doesn't have enough mock data for testing. Needed atleast 200 entries, but failed to retrieve one at index ${i}.`);
}
mockScores.push({
scoreID: i.toString(),
userID: 1,
game: "iidx",
playtype: "SP",
isPrimary: true,
chartID: charts[i].chartID,
songID: charts[i].songID,
chartID: chart.chartID,
songID: chart.songID,
calculatedData: {
ktLampRating: i,
},
@@ -8,6 +8,7 @@ import { GetGamePTConfig } from "tachi-common";
import { FormatPrError, optNull } from "utils/prudence";
import { GetUGPT } from "utils/req-tachi-data";
import { FormatUserDoc } from "utils/user";
import type { UGPTSettings } from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -47,7 +48,7 @@ router.patch(
};
}
const err = p(req.body, {
const err = p(req.safeBody, {
preferredScoreAlg: p.optional(p.nullable(p.isIn(gptConfig.scoreRatingAlgs))),
preferredSessionAlg: p.optional(p.nullable(p.isIn(gptConfig.sessionRatingAlgs))),
preferredProfileAlg: p.optional(p.nullable(p.isIn(gptConfig.profileRatingAlgs))),
@@ -65,43 +66,49 @@ router.patch(
});
}
if (typeof req.body.defaultTable === "string") {
const body = req.safeBody as Partial<UGPTSettings["preferences"]>;
if (typeof body.defaultTable === "string") {
const table = await db.tables.findOne({
game,
playtype,
tableID: req.body.defaultTable,
tableID: body.defaultTable,
});
if (!table) {
return res.status(400).json({
success: false,
description: `The table (${req.body.defaultTable}) does not exist (and therefore cannot be set as a default).`,
description: `The table (${body.defaultTable}) does not exist (and therefore cannot be set as a default).`,
});
}
}
const updateQuery: Record<string, string> = {};
const updateQuery: Record<string, string | null> = {};
// @warning Slightly icky dynamic prop assignment instead of copypasta.
for (const key of ["Score", "Session", "Profile"]) {
const k = `preferred${key}Alg`;
for (const key of ["Score", "Session", "Profile"] as const) {
const k = `preferred${key}Alg` as const;
if (req.body[k] !== undefined) {
updateQuery[`preferences.${k}`] = req.body[k];
const value = body[k];
if (value !== undefined) {
updateQuery[`preferences.${k}`] = value;
}
}
if (req.body.scoreBucket !== undefined) {
updateQuery[`preferences.scoreBucket`] = req.body.scoreBucket;
if (body.scoreBucket !== undefined) {
updateQuery[`preferences.scoreBucket`] = body.scoreBucket;
}
if (req.body.defaultTable !== undefined) {
updateQuery[`preferences.defaultTable`] = req.body.defaultTable;
if (body.defaultTable !== undefined) {
updateQuery[`preferences.defaultTable`] = body.defaultTable;
}
if (req.body.gameSpecific) {
for (const key in req.body.gameSpecific) {
updateQuery[`preferences.gameSpecific.${key}`] = req.body.gameSpecific[key];
if (body.gameSpecific) {
for (const [key, value] of Object.entries(body.gameSpecific)) {
// @ts-expect-error This is a very hacky way of applying changes.
// However, we know this to be correct, so we're just going to ignore it.
updateQuery[`preferences.gameSpecific.${key}`] = value;
}
}
@@ -167,21 +167,21 @@ router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), as
const gptConfig = GetGamePTConfig(game, playtype);
if (!Array.isArray(req.body)) {
if (!Array.isArray(req.safeBody)) {
return res.status(400).json({
success: false,
description: `No stats provided, or was not an array.`,
});
}
if (req.body.length > 6) {
if (req.safeBody.length > 6) {
return res.status(400).json({
success: false,
description: `You are only allowed 6 stats at once.`,
});
}
for (const stat of req.body) {
for (const stat of req.safeBody) {
let err;
if (stat?.mode === "chart") {
@@ -275,7 +275,7 @@ router.put("/", RequireAuthedAsUser, RequirePermissions("customise_profile"), as
},
{
$set: {
"preferences.stats": req.body,
"preferences.stats": req.safeBody,
},
}
);
@@ -666,6 +666,10 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/targets/goals/:goalID",
await db["goal-subs"].insert(IIDXSPMilestoneGoalSubs);
await db.milestones.insert(TestingIIDXSPMilestone);
if (!IIDXSPMilestoneGoalSubs[0] || !IIDXSPMilestoneGoals[0]) {
throw new Error(`Expected atleast one milestone goal or sub to work with?`);
}
const res = await mockApi.get(
`/api/v1/users/1/games/iidx/SP/targets/goals/${IIDXSPMilestoneGoalSubs[0].goalID}`
);
@@ -149,7 +149,7 @@ router.post(
});
}
const data = req.body as GoalCreationBody;
const data = req.safeBody as GoalCreationBody;
let goal;
@@ -2,10 +2,10 @@
import { RequireSelfRequestFromUser } from "../../middleware";
import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TACHI_DATA } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import prValidate from "server/middleware/prudence-validate";
import { RequireKamaitachi } from "server/middleware/type-require";
import { IsNonEmptyString } from "utils/misc";
import { GetArcAuth } from "utils/queries/auth";
import { GetTachiData } from "utils/req-tachi-data";
import { FormatUserDoc } from "utils/user";
@@ -46,7 +46,12 @@ router.get("/", async (req, res) => {
router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req, res) => {
const user = GetTachiData(req, "requestedUser");
if (Object.keys(req.body).length === 0) {
const body = req.safeBody as {
iidx?: string | null;
sdvx?: string | null;
};
if (Object.keys(body).length === 0) {
return res.status(400).json({
success: false,
description: `Invalid request to modify nothing.`,
@@ -63,7 +68,9 @@ router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req
for (const key of ["iidx", "sdvx"] as const) {
const importType = `api/arc-${key}` as const;
if (req.body[key] === null) {
const value = body[key];
if (value === null) {
logger.info(`User ${FormatUserDoc(user)} removed ARC integration for ${importType}.`);
await db["arc-saved-profiles"].remove(
@@ -75,7 +82,7 @@ router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req
single: true,
}
);
} else if (req.body[key]) {
} else if (IsNonEmptyString(value)) {
if (existingData[key]) {
logger.info(`User updated ARC integration for ${importType}.`);
await db["arc-saved-profiles"].update(
@@ -85,7 +92,7 @@ router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req
},
{
$set: {
accountID: req.body[key],
accountID: value,
},
}
);
@@ -94,7 +101,7 @@ router.patch("/", prValidate({ iidx: "*?string", sdvx: "*?string" }), async (req
await db["arc-saved-profiles"].insert({
userID: user.id,
forImportType: importType,
accountID: req.body[key],
accountID: value,
});
}
}
@@ -1,7 +1,6 @@
import { RequireSelfRequestFromUser } from "../../middleware";
import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TACHI_DATA } from "lib/constants/tachi";
import prValidate from "server/middleware/prudence-validate";
import { RequireKamaitachi } from "server/middleware/type-require";
import { DeleteUndefinedProps } from "utils/misc";
@@ -44,7 +43,12 @@ router.patch(
"/settings",
prValidate({ cards: optNull(["string"]), forceStaticImport: "*?boolean" }),
async (req, res) => {
if (req.body.cards && req.body.cards.length > 6) {
const body = req.safeBody as {
cards?: Array<string> | null;
forceStaticImport?: boolean | null;
};
if (body.cards && body.cards.length > 6) {
return res.status(400).json({
success: false,
description: `You cannot have more than 6 card filters at once.`,
@@ -53,7 +57,7 @@ router.patch(
const user = GetTachiData(req, "requestedUser");
const modifyDocument = req.body;
const modifyDocument = req.safeBody;
DeleteUndefinedProps(modifyDocument);
@@ -73,7 +73,7 @@ router.post(
const user = GetTachiData(req, "requestedUser");
const kaiType = NotNullish(req.params.kaiType).toUpperCase() as "EAG" | "FLO" | "MIN";
const body = req.body as {
const body = req.safeBody as {
code: string;
};
@@ -89,7 +89,7 @@ router.patch(
async (req, res) => {
const user = NotNullish(req[SYMBOL_TACHI_DATA]?.requestedUser);
const body = req.body as UserPatchBody;
const body = req.safeBody as UserPatchBody;
if (Object.keys(body).length === 0) {
return res.status(400).json({
@@ -311,7 +311,7 @@ router.post(
"!oldPassword": ValidatePassword,
}),
async (req, res) => {
const body = req.body as {
const body = req.safeBody as {
"!password": string;
"!oldPassword": string;
};
@@ -1,10 +1,8 @@
import { RequireSelfRequestFromUser } from "../middleware";
import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TACHI_DATA } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import prValidate from "server/middleware/prudence-validate";
import { DeleteUndefinedProps } from "utils/misc";
import { GetTachiData } from "utils/req-tachi-data";
import { FormatUserDoc, GetSettingsForUser } from "utils/user";
@@ -61,16 +59,14 @@ router.patch(
async (req, res) => {
const user = GetTachiData(req, "requestedUser");
const preferences = {
invisible: req.body.invisible,
developerMode: req.body.developerMode,
contentiousContent: req.body.contentiousContent,
advancedMode: req.body.advancedMode,
deletableScores: req.body.deletableScores,
const preferences = req.safeBody as {
invisible?: boolean;
developerMode?: boolean;
contentiousContent?: boolean;
advancedMode?: boolean;
deletableScores?: boolean;
};
DeleteUndefinedProps(preferences);
if (Object.keys(preferences).length === 0) {
return res.status(400).json({
success: false,
@@ -13,10 +13,10 @@ router.use(RequirePermissions("submit_score"));
*/
router.post("/score/submit", async (req, res) => {
const responseData = await ExpressWrappedScoreImportMain(
req[SYMBOL_TACHI_API_AUTH]!.userID!,
req[SYMBOL_TACHI_API_AUTH].userID!,
false,
"ir/barbatos",
[req.body]
[req.safeBody]
);
return res.status(responseData.statusCode).json(responseData.body);
@@ -2,11 +2,9 @@ import deepmerge from "deepmerge";
import { PBScoreDocument } from "tachi-common";
import t from "tap";
import ResetDBState from "test-utils/resets";
import { GetKTDataJSON } from "test-utils/test-data";
import { BMSGazerChart, GetKTDataJSON } from "test-utils/test-data";
import { TachiScoreDataToBeatorajaFormat } from "./convert-scores";
const gazerChart = GetKTDataJSON("./tachi/bms-gazer-chart.json");
const pbScore = {
composedFrom: {
lampPB: "mock_lampPB",
@@ -18,7 +16,7 @@ const pbScore = {
},
playtype: "7K",
scoreMeta: {},
chartID: gazerChart.chartID,
chartID: BMSGazerChart.chartID,
userID: 1,
} as unknown as PBScoreDocument<"bms:7K" | "bms:14K">;
@@ -28,9 +26,9 @@ t.test("#TachiScoreDataToBeatorajaFormat", (t) => {
t.test("Should convert score.", (t) => {
const res = TachiScoreDataToBeatorajaFormat(
pbScore,
gazerChart.data.hashSHA256,
BMSGazerChart.data.hashSHA256,
"",
gazerChart.data.notecount,
BMSGazerChart.data.notecount,
0
);
@@ -71,9 +69,9 @@ t.test("#TachiScoreDataToBeatorajaFormat", (t) => {
t.test("Should fake epg/egr data if the score doesn't have it..", (t) => {
const res = TachiScoreDataToBeatorajaFormat(
deepmerge(pbScore, { scoreData: { score: 999 } }),
gazerChart.data.hashSHA256,
BMSGazerChart.data.hashSHA256,
"",
gazerChart.data.notecount,
BMSGazerChart.data.notecount,
0
);
@@ -114,9 +112,9 @@ t.test("#TachiScoreDataToBeatorajaFormat", (t) => {
t.test("Should emplace username if requestingUserID is not the pbscore owner", (t) => {
const res = TachiScoreDataToBeatorajaFormat(
pbScore,
gazerChart.data.hashSHA256,
BMSGazerChart.data.hashSHA256,
"test_zkldi",
gazerChart.data.notecount,
BMSGazerChart.data.notecount,
0
);
@@ -4,7 +4,7 @@ import { PublicUserDocument } from "tachi-common";
import t from "tap";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
import { GetKTDataJSON } from "test-utils/test-data";
import { GetKTDataJSON, MockBeatorajaBMSScore, MockBeatorajaPMSScore } from "test-utils/test-data";
t.test("POST /ir/beatoraja/submit-score", (t) => {
t.beforeEach(ResetDBState);
@@ -20,15 +20,12 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
})
);
const bmsScoreReq = GetKTDataJSON("./beatoraja/base.json");
const pmsScoreReq = GetKTDataJSON("./beatoraja/pms-base.json");
t.test("Should import a valid BMS score.", async (t) => {
const res = await mockApi
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(bmsScoreReq);
.send(MockBeatorajaBMSScore);
t.equal(res.status, 200);
@@ -67,7 +64,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(pmsScoreReq);
.send(MockBeatorajaPMSScore);
t.equal(res.status, 200);
@@ -106,7 +103,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(deepmerge(pmsScoreReq, { score: { deviceType: "KEYBOARD" } }));
.send(deepmerge(MockBeatorajaPMSScore, { score: { deviceType: "KEYBOARD" } }));
t.equal(res.status, 200);
@@ -145,7 +142,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(deepmerge(bmsScoreReq, { client: "INVALID" }));
.send(deepmerge(MockBeatorajaBMSScore, { client: "INVALID" }));
t.equal(res.status, 400);
@@ -160,7 +157,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(deepmerge(bmsScoreReq, { client: "beatoraja 0.8.0" }));
.send(deepmerge(MockBeatorajaBMSScore, { client: "beatoraja 0.8.0" }));
t.equal(res.status, 400);
@@ -175,7 +172,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(deepmerge(pmsScoreReq, { client: "LR2oraja 0.8.0" }));
.send(deepmerge(MockBeatorajaPMSScore, { client: "LR2oraja 0.8.0" }));
t.equal(res.status, 400);
@@ -190,7 +187,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(deepmerge(bmsScoreReq, { score: { exscore: -1 } }));
.send(deepmerge(MockBeatorajaBMSScore, { score: { exscore: -1 } }));
t.equal(res.status, 400);
@@ -205,7 +202,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(deepmerge(bmsScoreReq, { chart: { title: null } }));
.send(deepmerge(MockBeatorajaBMSScore, { chart: { title: null } }));
t.equal(res.status, 400);
@@ -221,7 +218,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(
deepmerge(bmsScoreReq, {
deepmerge(MockBeatorajaBMSScore, {
chart: { sha256: "new_chart", md5: "new_md5" },
score: { sha256: "new_chart", md5: "new_md5" },
})
@@ -282,7 +279,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer mock_token")
.send(
deepmerge(bmsScoreReq, {
deepmerge(MockBeatorajaBMSScore, {
chart: { sha256: "new_chart", md5: "new_md5" },
score: { sha256: "new_chart", md5: "new_md5" },
})
@@ -295,7 +292,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer token2")
.send(
deepmerge(bmsScoreReq, {
deepmerge(MockBeatorajaBMSScore, {
chart: { sha256: "new_chart", md5: "new_md5" },
score: { sha256: "new_chart", md5: "new_md5" },
})
@@ -314,7 +311,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer token3")
.send(
deepmerge(bmsScoreReq, {
deepmerge(MockBeatorajaBMSScore, {
chart: { sha256: "new_chart", md5: "new_md5" },
score: { sha256: "new_chart", md5: "new_md5" },
})
@@ -347,7 +344,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
const res = await mockApi
.post("/ir/beatoraja/submit-score")
.set("X-TachiIR-Version", "v2.0.0")
.send(bmsScoreReq);
.send(MockBeatorajaBMSScore);
t.equal(res.status, 401);
@@ -360,7 +357,7 @@ t.test("POST /ir/beatoraja/submit-score", (t) => {
.set("X-TachiIR-Version", "v2.0.0")
.set("Authorization", "Bearer invalid_token")
.send(bmsScoreReq);
.send(MockBeatorajaBMSScore);
t.equal(res.status, 401);
@@ -29,7 +29,7 @@ router.post("/submit-score", RequireNotGuest, async (req, res) => {
const userID = NotNullish(req[SYMBOL_TACHI_API_AUTH].userID);
const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/beatoraja", [
req.body,
req.safeBody,
userID,
]);
@@ -43,7 +43,7 @@ router.post("/submit-score", RequireNotGuest, async (req, res) => {
// the chart and score values were atleast typed correctly
// and can afford to make this assertion.
if (type === "KTDataNotFound") {
const { chart } = req.body as { chart: BeatorajaChart };
const { chart } = req.safeBody as { chart: BeatorajaChart };
const orphanInfo: { userIDs: Array<integer> } | null = await db[
"orphan-chart-queue"
@@ -56,7 +56,7 @@ router.post("/submit-score", RequireNotGuest, async (req, res) => {
if (!orphanInfo) {
logger.warn(`Chart '${chart.sha256}' got KTDataNotFound, but was not orphaned?`, {
body: req.body as unknown,
body: req.safeBody as unknown,
});
return res.status(400).json({
@@ -192,7 +192,7 @@ router.post(
}),
RequireNotGuest,
async (req, res) => {
const body = req.body as {
const body = req.safeBody as {
course: {
charts: Array<{ md5: string }>;
constraint: Array<"GAUGE_LR2" | "LN" | "MIRROR">;
@@ -5,7 +5,7 @@ import t from "tap";
import { CreateFakeAuthCookie } from "test-utils/fake-auth";
import mockApi from "test-utils/mock-api";
import ResetDBState from "test-utils/resets";
import { GetKTDataJSON } from "test-utils/test-data";
import { FakeChunitachiBatchManual, FakeSmallBatchManual, GetKTDataJSON } from "test-utils/test-data";
t.test("POST /ir/direct-manual/import", async (t) => {
const cookie = await CreateFakeAuthCookie(mockApi);
@@ -38,7 +38,7 @@ t.test("POST /ir/direct-manual/import", async (t) => {
const res = await mockApi
.post("/ir/direct-manual/import")
.set("Cookie", cookie)
.send(GetKTDataJSON("./batch-manual/small-file.json"));
.send(FakeSmallBatchManual);
t.equal(res.body.success, true, "Should be successful");
@@ -62,7 +62,7 @@ t.test("POST /ir/direct-manual/import", async (t) => {
t.test("Should require authentication.", async (t) => {
const res = await mockApi
.post("/ir/direct-manual/import")
.send(GetKTDataJSON("./batch-manual/small-file.json"));
.send(FakeSmallBatchManual);
t.equal(res.statusCode, 401);
@@ -73,7 +73,7 @@ t.test("POST /ir/direct-manual/import", async (t) => {
const res = await mockApi
.post("/ir/direct-manual/import")
.set("Authorization", "Bearer invalid_token")
.send(GetKTDataJSON("./batch-manual/small-file.json"));
.send(FakeSmallBatchManual);
t.equal(res.statusCode, 401);
@@ -90,13 +90,11 @@ t.test("POST /ir/direct-manual/import", async (t) => {
})
);
const chunitachiBody = GetKTDataJSON("./batch-manual/chunitachi.json");
t.test("Should work for CHUNITACHI requests", async (t) => {
const res = await mockApi
.post("/ir/direct-manual/import")
.set("Authorization", `Bearer mock_token`)
.send(chunitachiBody);
.send(FakeChunitachiBatchManual);
t.equal(res.body.success, true, "Should be successful");
@@ -121,7 +119,7 @@ t.test("POST /ir/direct-manual/import", async (t) => {
});
t.test("Should require authentication.", async (t) => {
const res = await mockApi.post("/ir/direct-manual/import").send(chunitachiBody);
const res = await mockApi.post("/ir/direct-manual/import").send(FakeChunitachiBatchManual);
t.equal(res.statusCode, 401);
@@ -132,7 +130,7 @@ t.test("POST /ir/direct-manual/import", async (t) => {
const res = await mockApi
.post("/ir/direct-manual/import")
.set("Authorization", "Bearer invalid_token")
.send(chunitachiBody);
.send(FakeChunitachiBatchManual);
t.equal(res.statusCode, 401);
@@ -29,7 +29,7 @@ router.post(
userID: req[SYMBOL_TACHI_API_AUTH].userID!,
userIntent,
importType: "ir/direct-manual",
parserArguments: [req.body],
parserArguments: [req.safeBody],
};
// Fire the score import, but make no guarantees about its state.
@@ -51,7 +51,7 @@ router.post(
req[SYMBOL_TACHI_API_AUTH].userID!,
userIntent,
"ir/direct-manual",
[req.body]
[req.safeBody]
);
return res.status(importResponse.statusCode).json(importResponse.body);
@@ -184,7 +184,7 @@ const ValidateModelHeader: RequestHandler = (req, res, next) => {
};
const ValidateCards: RequestHandler = async (req, res, next) => {
const userID = req[SYMBOL_TACHI_API_AUTH]!.userID!;
const userID = req[SYMBOL_TACHI_API_AUTH].userID!;
const cardFilters = await db["fer-settings"].findOne({ userID });
@@ -244,7 +244,7 @@ router.post("/profile/submit", RequireInf2ModelHeaderOrForceStatic, (req, res) =
req[SYMBOL_TACHI_API_AUTH].userID!,
false,
"ir/fervidex-static",
[req.body, headers]
[req.safeBody, headers]
);
});
@@ -273,7 +273,7 @@ router.post("/score/submit", ValidateModelHeader, async (req, res) => {
req[SYMBOL_TACHI_API_AUTH].userID!,
true,
"ir/fervidex",
[req.body, headers]
[req.safeBody, headers]
);
if (!responseData.body.success) {
@@ -307,7 +307,7 @@ router.post(
),
ValidateModelHeader,
async (req, res) => {
const body = req.body as {
const body = req.safeBody as {
cleared: boolean;
course_id: integer;
play_style: 0 | 1;
@@ -4,6 +4,7 @@ import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
import { ParseEA3SoftID } from "utils/ea3id";
import { IsNullishOrEmptyStr } from "utils/misc";
import type { RequestHandler } from "express";
const router: Router = Router({ mergeParams: true });
@@ -13,9 +14,9 @@ const logger = CreateLogCtx(__filename);
const ValidateHeaders: RequestHandler = (req, res, next) => {
const agent = req.header("User-Agent");
if (!agent) {
if (IsNullishOrEmptyStr(agent)) {
logger.debug(
`Rejected KsHook client with no agent from user ${req[SYMBOL_TACHI_API_AUTH].userID!}.`
`Rejected KsHook client with no agent from user ${req[SYMBOL_TACHI_API_AUTH].userID}.`
);
return res.status(400).json({
success: false,
@@ -25,9 +26,7 @@ const ValidateHeaders: RequestHandler = (req, res, next) => {
if (!agent.startsWith("kshook/")) {
logger.info(
`Rejected KsHook client with invalid agent ${agent} from user ${req[
SYMBOL_TACHI_API_AUTH
].userID!}.`
`Rejected KsHook client with invalid agent ${agent} from user ${req[SYMBOL_TACHI_API_AUTH].userID}.`
);
return res.status(400).json({
success: false,
@@ -40,9 +39,9 @@ const ValidateHeaders: RequestHandler = (req, res, next) => {
const softID = req.header("X-Software-Model");
if (!softID) {
if (IsNullishOrEmptyStr(softID)) {
logger.debug(
`received request without X-Software-Model from ${req[SYMBOL_TACHI_API_AUTH].userID!}.`
`received request without X-Software-Model from ${req[SYMBOL_TACHI_API_AUTH].userID}.`
);
return res.status(400).json({
success: false,
@@ -63,7 +62,7 @@ const ValidateHeaders: RequestHandler = (req, res, next) => {
});
}
} catch (err) {
logger.info(`Invalid softID from ${req[SYMBOL_TACHI_API_AUTH].userID!}.`, { err });
logger.info(`Invalid softID from ${req[SYMBOL_TACHI_API_AUTH].userID}.`, { err });
return res.status(400).json({
success: false,
error: `Invalid X-Software-Model.`,
@@ -85,7 +84,7 @@ router.post("/score/save", async (req, res) => {
req[SYMBOL_TACHI_API_AUTH].userID!,
true,
"ir/kshook-sv6c",
[req.body]
[req.safeBody]
);
if (!responseData.body.success) {
@@ -2,7 +2,7 @@ import { Router } from "express";
import db from "external/mongo/db";
import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi";
import { ExpressWrappedScoreImportMain } from "lib/score-import/framework/express-wrapper";
import { PR_LR2Hook } from "lib/score-import/import-types/ir/lr2hook/parser";
import { PR_LR2Hook as PR_LR2_HOOK } from "lib/score-import/import-types/ir/lr2hook/parser";
import { RequirePermissions } from "server/middleware/auth";
import prValidate from "server/middleware/prudence-validate";
import { UpdateClassIfGreater } from "utils/class";
@@ -22,7 +22,7 @@ router.post("/import", async (req, res) => {
req[SYMBOL_TACHI_API_AUTH].userID!,
false,
"ir/lr2hook",
[req.body]
[req.safeBody]
);
return res.status(importRes.statusCode).json(importRes.body);
@@ -33,11 +33,13 @@ router.post("/import", async (req, res) => {
*
* @name POST /ir/lr2hook/import/course
*/
router.post("/import/course", prValidate(PR_LR2Hook), async (req, res) => {
router.post("/import/course", prValidate(PR_LR2_HOOK), async (req, res) => {
// notably, courses in LR2 are actually identical to scores. They have all
// the same fields in all the same ways. The only significant difference is that
// the md5 field is 4 fields conjoined, rather than just one.
const score: LR2HookScore = req.body;
// This type assertion is safe due to the prValidate call above.
const score = req.safeBody as unknown as LR2HookScore;
if (score.scoreData.notesPlayed !== score.scoreData.notesTotal) {
return res.status(200).json({
@@ -61,7 +63,7 @@ router.post("/import/course", prValidate(PR_LR2Hook), async (req, res) => {
});
}
const userID = req[SYMBOL_TACHI_API_AUTH]!.userID!;
const userID = req[SYMBOL_TACHI_API_AUTH].userID!;
const result = await UpdateClassIfGreater(
userID,
@@ -4,7 +4,7 @@ import db from "external/mongo/db";
import { CDNStoreOrOverwrite } from "lib/cdn/cdn";
import { GetUSCIRReplayURL } from "lib/cdn/url-format";
import { ONE_MEGABYTE } from "lib/constants/filesize";
import { SYMBOL_TACHI_API_AUTH, SYMBOL_TACHI_DATA } from "lib/constants/tachi";
import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi";
import { USCIR_MAX_LEADERBOARD_N } from "lib/constants/usc-ir";
import CreateLogCtx from "lib/logger/logger";
import { AssertStrAsPositiveNonZeroInt } from "lib/score-import/framework/common/string-asserts";
@@ -116,10 +116,19 @@ const RetrieveChart: RequestHandler = async (req, res, next) => {
playtype: req.params.playtype as Playtypes["usc"],
});
if (!chart) {
return res.status(200).json({
statusCode: STATUS_CODES.NOT_FOUND,
description: `This IR doesn't have any record data yet, or ${
ServerConfig.USC_QUEUE_SIZE
} ${
ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have"
} not played the chart yet.`,
});
}
AssignToReqTachiData(req, {
uscChartDoc: (chart ?? undefined) as
| ChartDocument<"usc:Controller" | "usc:Keyboard">
| undefined,
uscChartDoc: chart as ChartDocument<"usc:Controller" | "usc:Keyboard">,
});
next();
@@ -131,15 +140,6 @@ const RetrieveChart: RequestHandler = async (req, res, next) => {
* @name GET /ir/usc/:playtype/charts/:chartHash
*/
router.get("/charts/:chartHash", RetrieveChart, (req, res) => {
const chart = GetTachiData(req, "uscChartDoc");
if (!chart) {
return res.status(200).json({
statusCode: STATUS_CODES.NOT_FOUND,
description: "This chart is not available on the IR yet, more people need to play it!",
});
}
return res.status(200).json({
statusCode: STATUS_CODES.SUCCESS,
description: "This chart is tracked by the IR.",
@@ -155,17 +155,6 @@ router.get("/charts/:chartHash", RetrieveChart, (req, res) => {
router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => {
const chart = GetTachiData(req, "uscChartDoc");
if (!chart) {
return res.status(200).json({
statusCode: STATUS_CODES.NOT_FOUND,
description: `This IR doesn't have any record data yet, or ${
ServerConfig.USC_QUEUE_SIZE
} ${
ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have"
} not played the chart yet.`,
});
}
const serverRecord = (await db["personal-bests"].findOne({
chartID: chart.chartID,
"rankingData.rank": 1,
@@ -195,17 +184,6 @@ router.get("/charts/:chartHash/record", RetrieveChart, async (req, res) => {
router.get("/charts/:chartHash/leaderboard", RetrieveChart, async (req, res) => {
const chart = GetTachiData(req, "uscChartDoc");
if (!chart) {
return res.status(200).json({
statusCode: STATUS_CODES.NOT_FOUND,
description: `This IR doesn't have any record data yet, or ${
ServerConfig.USC_QUEUE_SIZE
} ${
ServerConfig.USC_QUEUE_SIZE === 1 ? "person has" : "people have"
} not played the chart yet.`,
});
}
if (!(typeof req.query.mode === "string" && ["best", "rivals"].includes(req.query.mode))) {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
@@ -285,7 +263,7 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
const playtype = req.params.playtype as Playtypes["usc"];
const chartErr = p(
req.body.chart,
req.safeBody.chart,
PR_USCIR_CHART_DOC,
{},
{
@@ -301,17 +279,17 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
});
}
const uscChart = req.body.chart as USCClientChart;
const uscChart = req.safeBody.chart as USCClientChart;
const chartDoc = (await db.charts.usc.findOne({
"data.hashSHA1": uscChart.chartHash,
playtype,
})) as ChartDocument<"usc:Controller" | "usc:Keyboard"> | null;
const userID = req[SYMBOL_TACHI_API_AUTH]!.userID!;
const userID = req[SYMBOL_TACHI_API_AUTH].userID!;
const importRes = await ExpressWrappedScoreImportMain(userID, false, "ir/usc", [
req.body,
req.safeBody,
uscChart.chartHash,
playtype,
]);
@@ -332,20 +310,15 @@ router.post("/scores", RequirePermissions("submit_score"), async (req, res) => {
// If the import failed, AND the import failure WAS NOT that the chart didnt exist
// report that error instead.
if (importDoc.errors[0]?.type && importDoc.errors[0].type !== "KTDataNotFound") {
logger.info(
`USC Import Failed ${importDoc.errors[0].message ?? "with null error message?"}`,
{
importDoc,
userID,
}
);
if (importDoc.errors[0] && importDoc.errors[0].type !== "KTDataNotFound") {
logger.info(`USC Import Failed ${importDoc.errors[0].message}`, {
importDoc,
userID,
});
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: `${importDoc.errors[0].type} ${
importDoc.errors[0].message ?? "No Error Message"
}`,
description: `${importDoc.errors[0].type} ${importDoc.errors[0].message}`,
});
}
@@ -405,7 +378,7 @@ router.post(
RequirePermissions("submit_score"),
CreateMulterSingleUploadMiddleware("replay", ONE_MEGABYTE, logger, false),
async (req, res) => {
if (typeof req.body.identifier !== "string") {
if (typeof req.safeBody.identifier !== "string") {
return res.status(200).json({
statusCode: STATUS_CODES.BAD_REQ,
description: "No Identifier Provided.",
@@ -425,8 +398,8 @@ router.post(
// Otherwise, anyone could overwrite anyone elses
// score replays!
const correspondingScore = await db.scores.findOne({
userID: req[SYMBOL_TACHI_API_AUTH]!.userID!,
scoreID: req.body.identifier,
userID: req[SYMBOL_TACHI_API_AUTH].userID!,
scoreID: req.safeBody.identifier,
game: "usc",
});
+33 -10
View File
@@ -1,17 +1,21 @@
// THIS IMPORT **MUST** GO HERE. DO NOT MOVE IT. IT MUST OCCUR BEFORE ANYTHING HAPPENS WITH EXPRESS
// BUT AFTER EXPRESS IS IMPORTED.
// eslint-disable-next-line import/order
import express from "express";
import "express-async-errors";
import { RequestLoggerMiddleware } from "./middleware/request-logger";
import mainRouter from "./router/router";
import connectRedis from "connect-redis";
import express from "express";
import expressSession from "express-session";
import { RedisClient } from "external/redis/redis";
import helmet from "helmet";
import { SYMBOL_TACHI_API_AUTH } from "lib/constants/tachi";
import CreateLogCtx from "lib/logger/logger";
import { Environment, ServerConfig, TachiConfig } from "lib/setup/config";
import { IsNonEmptyString, IsRecord } from "utils/misc";
import type { Express } from "express";
import "express-async-errors";
import type { integer } from "tachi-common";
import { IsNonEmptyString } from "utils/misc";
const logger = CreateLogCtx(__filename);
@@ -53,9 +57,17 @@ if (Environment.nodeEnv !== "production" && IsNonEmptyString(ServerConfig.CLIENT
bootInfo: true,
});
// Note: we have to assign it here to make sure it doesn't get modified!
// If we try and use ServerConfig.CLIENT_DEV_SERVER inside the callback, TS rightly
// complains that this value might end up being mutated to null/undefined.
//
// Even though we don't do that,
// we may aswell be correct about the whole thing.
const clientDevServerLocation = ServerConfig.CLIENT_DEV_SERVER;
// Allow CORS requests from another server (since we have our dev server hosted separately).
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", ServerConfig.CLIENT_DEV_SERVER!);
res.header("Access-Control-Allow-Origin", clientDevServerLocation);
res.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept, X-User-Intent"
@@ -103,11 +115,14 @@ app.use(express.json({ limit: "4mb" }));
app.use((req, res, next) => {
// Always mount an empty req body. We operate under the assumption that req.body is
// always defined.
if (req.method !== "GET" && !req.body) {
// always defined as atleast an object.
if (req.method !== "GET" && (typeof req.body !== "object" || req.body === null)) {
req.body = {};
}
// req.safeBody *is* just a type-safe req.body!
req.safeBody = req.body as Record<string, unknown>;
next();
});
@@ -143,8 +158,9 @@ interface ExpressJSONErr extends SyntaxError {
message: string;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, next) => {
const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, _next) => {
logger.info(`MAIN_ERR_HANDLER hit by request.`, { url: req.originalUrl });
if (err instanceof SyntaxError) {
const expErr: ExpressJSONErr = err as ExpressJSONErr;
@@ -159,14 +175,21 @@ const MAIN_ERR_HANDLER: express.ErrorRequestHandler = (err, req, res, next) => {
// else, this isn't a JSON parsing error
}
if (err.type === "entity.too.large") {
const unknownErr = err as unknown;
if (IsRecord(unknownErr) && unknownErr.type === "entity.too.large") {
return res.status(413).json({
success: false,
description: "Your request body was too large. The limit is 4MB.",
});
}
logger.error("Fatal error propagated to server root? ", { err, route: req.route });
logger.error("Fatal error propagated to server root? ", {
err: unknownErr,
url: req.originalUrl,
authInfo: req[SYMBOL_TACHI_API_AUTH],
});
return res.status(500).json({
success: false,
description: "A fatal internal server error has occured.",
+9 -1
View File
@@ -23,7 +23,15 @@ export async function CreateFakeAuthCookie(mockApi: supertest.SuperTest<supertes
throw res.body;
}
return res.headers["set-cookie"] as Array<string>;
const headers = res.headers as Record<string, Array<string>>;
const setCookieHeader: Array<string> | undefined = headers["set-cookie"];
if (setCookieHeader === undefined) {
throw new Error(`Failed to login, no Set-Cookie returned in response?`);
}
return setCookieHeader;
}
// my local dev env hates this part because of pnpm
+13 -1
View File
@@ -3,6 +3,8 @@ import {
FakeImport,
FakeNotification,
FakeOtherUser,
HC511Goal,
HC511UserGoal,
TestingIIDXSPScore,
TestingIIDXSPScorePB,
TestingSDVXScore,
@@ -10,6 +12,8 @@ import {
import deepmerge from "deepmerge";
import type {
Game,
GoalDocument,
GoalSubscriptionDocument,
ImportDocument,
integer,
NotificationDocument,
@@ -39,7 +43,7 @@ export async function agta(ag: AsyncIterable<unknown> | Iterable<unknown>) {
export function dmf<T extends object>(base: T, modifant: Partial<T>): T {
return deepmerge(base, modifant, {
// The new array should replace the former one, instead of joining them together.
arrayMerge: (originalArray, newArray) => newArray,
arrayMerge: (originalArray, newArray) => newArray as Array<unknown>,
});
}
@@ -91,3 +95,11 @@ export function mkFakePBIIDXSP(modifant: Partial<PBScoreDocument<"iidx:SP">> = {
export function mkFakeNotification(modifant: Partial<NotificationDocument> = {}) {
return dmf(FakeNotification, modifant);
}
export function mkFakeGoal(modifant: Partial<GoalDocument> = {}) {
return dmf(HC511Goal, modifant);
}
export function mkFakeGoalSub(modifant: Partial<GoalSubscriptionDocument> = {}) {
return dmf(HC511UserGoal, modifant);
}
@@ -7,12 +7,17 @@
"notecount": 2256,
"hashMD5": "38616b85332037cc12924f2ae2840262",
"hashSHA256": "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d",
"tableFolders": []
"tableFolders": [
{
"level": "17",
"table": "★"
}
]
},
"tierlistInfo": {},
"level": "?",
"levelNum": 0,
"difficulty": "CUSTOM",
"difficulty": "CHART",
"playtype": "7K",
"isPrimary": true,
"versions": []
+2 -1
View File
@@ -6,7 +6,8 @@
"data": {
"subtitle": null,
"subartist": null,
"genre": "ELECTRANCE"
"genre": "ELECTRANCE",
"tableString": null
},
"searchTerms": [],
"altTitles": []
+20 -14
View File
@@ -7,7 +7,7 @@ import type { NodeFetch } from "utils/fetch";
* Creates a basic Fetch function used for statusCode checks.
*/
export function MockBasicFetch(data: Partial<Response>) {
return (async () => data) as unknown as NodeFetch;
return (() => data) as unknown as NodeFetch;
}
/**
@@ -15,18 +15,24 @@ export function MockBasicFetch(data: Partial<Response>) {
* a fake JSON -> data function at that key.
*/
export function MockJSONFetch(urlDataMap: Record<string, unknown>) {
return (async (url: string) => {
if (urlDataMap[url]) {
return {
status: 200,
json: async () => urlDataMap[url],
};
}
return ((url: string) =>
new Promise((resolve, reject) => {
if (url in urlDataMap) {
const fakeResponse = {
status: 200,
json: () => Promise.resolve(urlDataMap[url]),
} as unknown as Response;
throw new Error(
`Unexpected url ${url} - No Data Present? Valid urls are ${Object.keys(urlDataMap).join(
", "
)}`
);
}) as NodeFetch;
resolve(fakeResponse);
return;
}
reject(
new Error(
`Unexpected url ${url} - No Data Present? Valid urls are ${Object.keys(
urlDataMap
).join(", ")}`
)
);
})) as NodeFetch;
}
+2 -2
View File
@@ -1,8 +1,8 @@
export function MockMulterFile(buffer: Buffer, originalname: string) {
const mockFile: Express.Multer.File = {
const mockFile = {
originalname,
buffer,
};
} as unknown as Express.Multer.File;
return mockFile;
}
+19 -16
View File
@@ -8,6 +8,9 @@ import { Environment, ServerConfig } from "lib/setup/config";
import rimraf from "rimraf";
import fs from "fs";
import path from "path";
import type { StaticDatabases } from "external/mongo/db";
import type { ICollection } from "monk";
import type { Game } from "tachi-common";
// im installing an entire library for rm rf...
@@ -25,37 +28,37 @@ const DATA_DIR = path.join(__dirname, "./mock-db");
const CACHE: Record<string, Array<any>> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function ResetState(data: Array<any>, collection: any) {
async function ResetState(data: Array<any>, collection: ICollection) {
await collection.remove({});
await collection.insert(data);
}
function GetAndCache(filename: string, fileLoc: string) {
let collection;
function GetAndCache(
filename: string,
fileLoc: string
): { data: Array<unknown>; collection: ICollection } {
let collection: ICollection;
if (filename.startsWith("songs-")) {
// @ts-expect-error it's right, but we know what we're doing!
collection = db.songs[filename.split("-")[1]];
collection = db.songs[filename.split("-")[1] as Game];
} else if (filename.startsWith("charts-")) {
// @ts-expect-error see above
collection = db.charts[filename.split("-")[1]];
collection = db.charts[filename.split("-")[1] as Game];
} else if (filename in db) {
collection = db[filename as StaticDatabases];
} else {
// @ts-expect-error see above
collection = db[filename];
}
if (!collection) {
throw new Error(
`Panicked when trying to get collection for ${filename}. Does this collection exist?`
);
}
if (CACHE[filename]) {
return { data: CACHE[filename], collection };
const cacheExists = CACHE[filename];
if (cacheExists) {
return { data: cacheExists, collection };
}
const data = JSON.parse(fs.readFileSync(fileLoc, "utf-8"));
const data: unknown = JSON.parse(fs.readFileSync(fileLoc, "utf-8"));
if (!Array.isArray(data)) {
throw new Error(`Panic, ${filename} not JSONArray?`);
@@ -66,7 +69,7 @@ function GetAndCache(filename: string, fileLoc: string) {
return { data, collection };
}
let CACHE_FILENAMES: Array<string>;
let CACHE_FILENAMES: Array<string> | undefined;
export default async function ResetDBState() {
let files;
+2 -1
View File
@@ -3,4 +3,5 @@ import { monkDB } from "external/mongo/db";
SetIndexesForDB()
.then(monkDB.close)
.then(() => process.exit(0));
.then(() => process.exit(0))
.catch(() => process.exit(1));
@@ -1,3 +1,4 @@
import { IsNonEmptyString } from "utils/misc";
import fs from "fs";
import path from "path";
@@ -9,7 +10,7 @@ function ReadSnapshotData() {
let snapshots: Snapshots = {};
if (fs.existsSync(SNAP_PATH)) {
snapshots = JSON.parse(fs.readFileSync(SNAP_PATH, "utf-8"));
snapshots = JSON.parse(fs.readFileSync(SNAP_PATH, "utf-8")) as Snapshots;
}
return snapshots;
@@ -22,11 +23,11 @@ export function WriteSnapshotData() {
const snapshotData = ReadSnapshotData();
export function TestSnapshot(t: Tap.Test, value: string, testName: string) {
if (process.env.TAP_SNAPSHOT) {
if (IsNonEmptyString(process.env.TAP_SNAPSHOT)) {
snapshotData[testName] = value;
WriteSnapshotData();
} else {
if (!snapshotData[testName]) {
if (snapshotData[testName] === undefined) {
return t.fail(`No snapshot exists for ${testName}. Have you ran pnpm snap?`);
}
@@ -9,6 +9,8 @@ const files = glob.sync(path.join(__dirname, "../../", "**/*.test.ts"));
process.env.NODE_PATH = path.join(__dirname, "../../");
for (const file of files) {
// Deliberate -- we're doing hackery here.
// eslint-disable-next-line @typescript-eslint/no-require-imports
require(file);
}
+308 -6
View File
@@ -11,7 +11,9 @@ import { ApplyNTimes, RFA } from "utils/misc";
import fs from "fs";
import path from "path";
import type { DryScore } from "lib/score-import/framework/common/types";
import type { S3Score } from "lib/score-import/import-types/file/solid-state-squad/types";
import type { BarbatosScore } from "lib/score-import/import-types/ir/barbatos/types";
import type { FervidexScore } from "lib/score-import/import-types/ir/fervidex/types";
import type { KsHookSV6CScore } from "lib/score-import/import-types/ir/kshook-sv6c/types";
import type { LR2HookScore } from "lib/score-import/import-types/ir/lr2hook/types";
import type { USCClientScore } from "server/router/ir/usc/_playtype/types";
@@ -33,7 +35,9 @@ import type {
const file = (name: string) => path.join(__dirname, "/test-data", name);
export const GetKTDataJSON = (name: string) => JSON.parse(fs.readFileSync(file(name), "utf-8"));
export const GetKTDataJSON = (name: string) =>
JSON.parse(fs.readFileSync(file(name), "utf-8")) as unknown;
export const GetKTDataBuffer = (name: string) => fs.readFileSync(file(name));
export const TestingIIDXSPDryScore: DryScore<"iidx:SP"> = {
@@ -149,7 +153,9 @@ export const TestingSDVXScore: ScoreDocument<"sdvx:Single"> = {
calculatedData: {},
timeAchieved: 1619454485988,
songID: 1,
chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b", // albida adv
// albida adv
chartID: "5088a4d0e1ee9d0cc2f625934306e45b1a60699b",
highlight: false,
isPrimary: true,
comment: null,
@@ -334,6 +340,20 @@ export const BMSGazerChart: ChartDocument<"bms:7K"> = {
tierlistInfo: {},
};
export const BMSGazerSong: SongDocument<"bms"> = {
id: 27339,
title: "gazer [MANIAQ]",
artist: "scytheleg / obj.siokaze",
data: {
subtitle: null,
subartist: null,
genre: "ELECTRANCE",
tableString: null,
},
searchTerms: [],
altTitles: [],
};
export const CHUNITHMBBKKChart: ChartDocument<"chunithm:Single"> = {
rgcID: null,
chartID: "192b96bdb6150f80ba6412ce02df1249e16c0cb0",
@@ -550,7 +570,9 @@ export const TestingIIDXSPMilestoneSub: MilestoneSubscriptionDocument = {
wasInstantlyAchieved: false,
};
let KTDATA_CACHE: { songs: Array<unknown>; charts: Array<unknown> } | undefined;
let KTDATA_CACHE:
| { songs: Array<SongDocument<"iidx">>; charts: Array<ChartDocument<"iidx:DP" | "iidx:SP">> }
| undefined;
export async function LoadTachiIIDXData() {
let songs;
@@ -560,8 +582,11 @@ export async function LoadTachiIIDXData() {
songs = KTDATA_CACHE.songs;
charts = KTDATA_CACHE.charts;
} else {
songs = GetKTDataJSON("./tachi/tachi-songs-iidx.json");
charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json");
songs = GetKTDataJSON("./tachi/tachi-songs-iidx.json") as Array<SongDocument<"iidx">>;
charts = GetKTDataJSON("./tachi/tachi-charts-iidx.json") as Array<
ChartDocument<"iidx:DP" | "iidx:SP">
>;
KTDATA_CACHE = { songs, charts };
}
@@ -571,7 +596,7 @@ export async function LoadTachiIIDXData() {
await db.charts.iidx.insert(charts);
}
export const barbScore: BarbatosScore = {
export const MockBarbatosScore: BarbatosScore = {
clear_type: 2,
did_fail: false,
difficulty: 1,
@@ -700,3 +725,280 @@ export const FakeNotification: NotificationDocument = {
},
},
};
export const FervidexStaticBase = {
name: "AIXXE",
qpro: {
body: 189,
face: 138,
hair: 76,
hand: 145,
head: 0,
},
scores: {
"1000": {
spa: {
clear_type: 4,
ex_score: 1180,
miss_count: 40,
},
spn: {
clear_type: 7,
ex_score: 158,
miss_count: 0,
},
},
"1001": {
dph: {
clear_type: 3,
ex_score: 15,
miss_count: 1,
},
},
},
sp_dan: 15,
};
export const FervidexBaseScore: FervidexScore = {
bad: 0,
chart: "spa",
clear_type: 1,
combo_break: 6,
custom: false,
chart_sha256: "asdfasdf",
entry_id: 1000,
ex_score: 68,
fast: 0,
gauge: [100, 50],
ghost: [0, 2],
good: 0,
great: 0,
max_combo: 34,
option: {
gauge: "HARD",
range: "SUDDEN_PLUS",
style: "RANDOM",
},
pacemaker: {
name: "",
score: 363,
type: "PACEMAKER_A",
},
pgreat: 34,
poor: 6,
slow: 0,
};
export const FervidexBaseGSMScore: FervidexScore = {
bad: 0,
chart: "spa",
clear_type: 1,
combo_break: 6,
dead: {
measure: 18,
note: 40,
},
entry_id: 1000,
custom: false,
chart_sha256: "asdfasdf",
ex_score: 68,
fast: 0,
gauge: [100, 50],
ghost: [0, 2],
good: 0,
great: 0,
max_combo: 34,
option: {
gauge: "HARD",
range: "SUDDEN_PLUS",
style: "RANDOM",
},
pacemaker: {
name: "",
score: 363,
type: "PACEMAKER_A",
},
pgreat: 34,
poor: 6,
slow: 0,
"2dx-gsm": {
EASY: [0, 10],
NORMAL: [0, 20],
HARD: [20, 0],
EX_HARD: [10, 0],
},
};
export const MockBeatorajaBMSScore = {
chart: {
md5: "38616b85332037cc12924f2ae2840262",
sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d",
title: "GAZER [MANIAQ]",
subtitle: "",
genre: "TRANCE",
artist: "the dude who made gazer",
subartist: "",
url: "",
appendurl: "",
level: 8,
total: 220,
mode: "BEAT_7K",
lntype: 0,
judge: 100,
minbpm: 135,
maxbpm: 135,
notes: 568,
hasUndefinedLN: false,
hasLN: false,
hasCN: false,
hasHCN: false,
hasMine: false,
hasRandom: false,
hasStop: false,
values: {},
},
score: {
sha256: "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d",
lntype: 0,
player: "unknown",
clear: "Easy",
date: 0,
epg: 332,
lpg: 127,
egr: 65,
lgr: 21,
egd: 2,
lgd: 2,
ebd: 1,
lbd: 0,
epr: 7,
lpr: 11,
ems: 1,
lms: 0,
maxcombo: 223,
notes: 568,
passnotes: 568,
minbp: 20,
option: 2,
assist: 0,
gauge: -1,
deviceType: "BM_CONTROLLER",
judgeAlgorithm: "Combo",
rule: "LR2",
exscore: 1004,
},
client: "LR2oraja 0.8.0",
};
export const MockBeatorajaPMSScore = {
chart: {
md5: "d1253dd56bb2087d0b0d474f0d562aae",
sha256: "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d",
title: "GPMSAZER [MANIAQ]",
subtitle: "",
genre: "Annihilate the living",
artist: "Rocky",
subartist: "",
url: "",
appendurl: "",
level: 8,
total: 220,
mode: "POPN_9K",
lntype: 0,
judge: 100,
minbpm: 135,
maxbpm: 135,
notes: 568,
hasUndefinedLN: false,
hasLN: false,
hasCN: false,
hasHCN: false,
hasMine: false,
hasRandom: false,
hasStop: false,
values: {},
},
score: {
sha256: "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d",
lntype: 0,
player: "unknown",
clear: "Easy",
date: 0,
epg: 332,
lpg: 127,
egr: 65,
lgr: 21,
egd: 2,
lgd: 2,
ebd: 1,
lbd: 0,
epr: 7,
lpr: 11,
ems: 1,
lms: 0,
maxcombo: 223,
notes: 568,
passnotes: 568,
minbp: 20,
option: 2,
assist: 0,
gauge: -1,
deviceType: "BM_CONTROLLER",
judgeAlgorithm: "Combo",
rule: "LR2",
exscore: 1004,
},
client: "beatoraja 0.8.0",
};
export const MockParsedS3Score: S3Score = {
id: 187,
diff: "A",
songname: "5.1.1.",
styles: "7th",
exscore: 100,
scorebreakdown: {
justgreats: 25,
greats: 50,
good: 0,
bad: 0,
poor: 4,
},
mods: {},
cleartype: "perfect",
date: "2010-10-19 04:54:22",
};
export const FakeSmallBatchManual = {
meta: {
game: "iidx",
playtype: "SP",
service: "foobar",
},
scores: [
{
score: 500,
lamp: "HARD CLEAR",
matchType: "songTitle",
identifier: "5.1.1.",
difficulty: "ANOTHER",
},
],
};
export const FakeChunitachiBatchManual = {
meta: {
game: "chunithm",
playtype: "Single",
service: "ChunItachi",
},
scores: [
{
score: 900000,
lamp: "CLEAR",
matchType: "songTitle",
identifier: "B.B.K.K.B.K.K.",
difficulty: "BASIC",
},
],
};
@@ -1,17 +0,0 @@
{
"difficulty": 1,
"level": 10,
"song_id": 1,
"max_chain": 50,
"critical": 50,
"near_total": 30,
"near_fast": 20,
"near_slow": 10,
"score": 9500000,
"error": 5,
"percent": 81,
"did_fail": false,
"clear_type": 2,
"gauge_type": 0,
"is_skill_analyzer": false
}
@@ -1,60 +0,0 @@
{
"chart": {
"md5": "38616b85332037cc12924f2ae2840262",
"sha256": "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d",
"title": "GAZER [MANIAQ]",
"subtitle": "",
"genre": "TRANCE",
"artist": "the dude who made gazer",
"subartist": "",
"url": "",
"appendurl": "",
"level": 8,
"total": 220,
"mode": "BEAT_7K",
"lntype": 0,
"judge": 100,
"minbpm": 135,
"maxbpm": 135,
"notes": 568,
"hasUndefinedLN": false,
"hasLN": false,
"hasCN": false,
"hasHCN": false,
"hasMine": false,
"hasRandom": false,
"hasStop": false,
"values": {}
},
"score": {
"sha256": "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d",
"lntype": 0,
"player": "unknown",
"clear": "Easy",
"date": 0,
"epg": 332,
"lpg": 127,
"egr": 65,
"lgr": 21,
"egd": 2,
"lgd": 2,
"ebd": 1,
"lbd": 0,
"epr": 7,
"lpr": 11,
"ems": 1,
"lms": 0,
"maxcombo": 223,
"notes": 568,
"passnotes": 568,
"minbp": 20,
"option": 2,
"assist": 0,
"gauge": -1,
"deviceType": "BM_CONTROLLER",
"judgeAlgorithm": "Combo",
"rule": "LR2",
"exscore": 1004
},
"client": "LR2oraja 0.8.0"
}
@@ -1,60 +0,0 @@
{
"chart": {
"md5": "d1253dd56bb2087d0b0d474f0d562aae",
"sha256": "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d",
"title": "GPMSAZER [MANIAQ]",
"subtitle": "",
"genre": "Annihilate the living",
"artist": "Rocky",
"subartist": "",
"url": "",
"appendurl": "",
"level": 8,
"total": 220,
"mode": "POPN_9K",
"lntype": 0,
"judge": 100,
"minbpm": 135,
"maxbpm": 135,
"notes": 568,
"hasUndefinedLN": false,
"hasLN": false,
"hasCN": false,
"hasHCN": false,
"hasMine": false,
"hasRandom": false,
"hasStop": false,
"values": {}
},
"score": {
"sha256": "a10193f7ae05ce839292dc716f182fda0b1cc6ac5382c2056f37e22ffba87b7d",
"lntype": 0,
"player": "unknown",
"clear": "Easy",
"date": 0,
"epg": 332,
"lpg": 127,
"egr": 65,
"lgr": 21,
"egd": 2,
"lgd": 2,
"ebd": 1,
"lbd": 0,
"epr": 7,
"lpr": 11,
"ems": 1,
"lms": 0,
"maxcombo": 223,
"notes": 568,
"passnotes": 568,
"minbp": 20,
"option": 2,
"assist": 0,
"gauge": -1,
"deviceType": "BM_CONTROLLER",
"judgeAlgorithm": "Combo",
"rule": "LR2",
"exscore": 1004
},
"client": "beatoraja 0.8.0"
}
@@ -1,8 +0,0 @@
{
"title": "ALBIDA Powerless Mix",
"difficulty": "ADVANCED",
"level": "10",
"lamp": "EXCESSIVE COMPLETE",
"score": "9310699",
"exscore": "0"
}
@@ -1,32 +0,0 @@
{
"name": "AIXXE",
"qpro": {
"body": 189,
"face": 138,
"hair": 76,
"hand": 145,
"head": 0
},
"scores": {
"1000": {
"spa": {
"clear_type": 4,
"ex_score": 1180,
"miss_count": 40
},
"spn": {
"clear_type": 7,
"ex_score": 158,
"miss_count": 0
}
},
"1001": {
"dph": {
"clear_type": 3,
"ex_score": 15,
"miss_count": 1
}
}
},
"sp_dan": 15
}
@@ -1,57 +0,0 @@
{
"bad": 0,
"chart": "spa",
"clear_type": 1,
"combo_break": 6,
"dead": {
"measure": 18,
"note": 40
},
"entry_id": 1000,
"custom": false,
"chart_sha256": "asdfasdf",
"ex_score": 68,
"fast": 0,
"gauge": [
100,
50
],
"ghost": [
0,
2
],
"good": 0,
"great": 0,
"max_combo": 34,
"option": {
"gauge": "HARD",
"range": "SUDDEN_PLUS",
"style": "RANDOM"
},
"pacemaker": {
"name": "",
"score": 363,
"type": "PACEMAKER_A"
},
"pgreat": 34,
"poor": 6,
"slow": 0,
"2dx-gsm": {
"EASY": [
0,
10
],
"NORMAL": [
0,
20
],
"HARD": [
20,
0
],
"EX_HARD": [
10,
0
]
}
}
@@ -1,35 +0,0 @@
{
"bad": 0,
"chart": "spa",
"clear_type": 1,
"combo_break": 6,
"custom": false,
"chart_sha256": "asdfasdf",
"entry_id": 1000,
"ex_score": 68,
"fast": 0,
"gauge": [
100,
50
],
"ghost": [
0,
2
],
"good": 0,
"great": 0,
"max_combo": 34,
"option": {
"gauge": "HARD",
"range": "SUDDEN_PLUS",
"style": "RANDOM"
},
"pacemaker": {
"name": "",
"score": 363,
"type": "PACEMAKER_A"
},
"pgreat": 34,
"poor": 6,
"slow": 0
}
@@ -1,9 +0,0 @@
{
"music_id": 1000,
"play_type": "SINGLE",
"diff_type": "ANOTHER",
"score": 1000,
"miss_count": 21,
"clear_type": "CLEAR",
"update_time": "2021-03-24 07:15:22"
}
@@ -1,17 +0,0 @@
{
"id": 187,
"diff": "A",
"songname": "5.1.1.",
"styles": "7th",
"exscore": 100,
"scorebreakdown": {
"justgreats": 25,
"greats": 50,
"good": 0,
"bad": 0,
"poor": 4
},
"mods": {},
"cleartype": "perfect",
"date": "2010-10-19 04:54:22"
}
@@ -1,16 +0,0 @@
{
"songID": 27339,
"chartID": "88eb6cc5683e2740cbd07f588a5f3db1db8d467b",
"rgcID": null,
"data": {
"notecount": 2256,
"hashMD5": "38616b85332037cc12924f2ae2840262",
"hashSHA256": "195fe1be5c3e74fccd04dc426e05f8a9cfa8a1059c339d0a23e99f63661f0b7d"
},
"level": "?",
"levelNum": 0,
"difficulty": "CUSTOM",
"playtype": "7K",
"isPrimary": true,
"versions": []
}
@@ -1,12 +0,0 @@
{
"id": 27339,
"title": "gazer [MANIAQ]",
"artist": "scytheleg / obj.siokaze",
"data": {
"subtitle": null,
"subartist": null,
"genre": "ELECTRANCE"
},
"searchTerms": [],
"altTitles": []
}
@@ -24,8 +24,8 @@ export async function RecalcSessions(filter = {}) {
try {
c = CreateSessionCalcData(session.game, session.playtype, scores);
} catch (err) {
logger.error(`${session.game} (${session.playtype}) failed.`);
logger.warn(`Destroying session.`);
logger.error(`Recalcing ${session.game} (${session.playtype}) failed.`, { err });
logger.warn(`Destroying session!`);
await db.sessions.remove({ sessionID: session.sessionID });
continue;
}
-1
View File
@@ -14,7 +14,6 @@ export async function EfficientDBIterate<T, R>(
) {
let i = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
logger.info(`Running on ${i} - ${i + bucketSize} documents.`);
// eslint-disable-next-line no-await-in-loop
+53 -36
View File
@@ -40,39 +40,54 @@ export async function ResolveFolderToCharts(
let songs: Array<SongDocument> | null = null;
let charts: Array<ChartDocument>;
if (folder.type === "static") {
charts = await db.charts[folder.game].find(
deepmerge(filter, {
playtype: folder.playtype, // mandatory
chartID: { $in: folder.data },
})
);
} else if (folder.type === "songs") {
songs = await db.songs[folder.game].find(folder.data);
switch (folder.type) {
case "static": {
charts = await db.charts[folder.game].find(
deepmerge(filter, {
// Specifying playtype is mandatory, don't want to catch other charts.
playtype: folder.playtype,
chartID: { $in: folder.data },
})
);
break;
}
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 = TransposeFolderData(folder.data);
case "songs": {
songs = await db.songs[folder.game].find(folder.data);
logger.debug(`Transposed folder data in resolve-folder-to-charts.`, {
folder,
folderDataTransposed,
});
charts = await db.charts[folder.game].find(
deepmerge(filter, {
playtype: folder.playtype,
songID: { $in: songs.map((e) => e.id) },
})
);
break;
}
const fx = deepmerge.all([filter, { playtype: folder.playtype }, folderDataTransposed]);
case "charts": {
const folderDataTransposed = TransposeFolderData(folder.data);
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 });
logger.debug(`Transposed folder data in resolve-folder-to-charts.`, {
folder,
folderDataTransposed,
});
// @ts-expect-error See above
throw new Error(`Invalid folder ${folder.folderID}. Cannot resolve.`);
const fx = deepmerge.all([filter, { playtype: folder.playtype }, folderDataTransposed]);
charts = await db.charts[folder.game].find(fx);
break;
}
default: {
logger.error(
`Invalid folder at ${(folder as FolderDocument).folderID}. Cannot resolve.`,
{ folder }
);
throw new Error(
`Invalid folder ${(folder as FolderDocument).folderID}. Cannot resolve.`
);
}
}
if (getSongs) {
@@ -98,10 +113,14 @@ export async function ResolveFolderToCharts(
export function TransposeFolderData(obj: Record<string, unknown>) {
const transposedObj: Record<string, unknown> = {};
for (const key in obj) {
for (const key of Object.keys(obj)) {
const transposedKey = key.replace(/~/gu, "$").replace(/¬/gu, ".");
if (typeof obj[key] === "object" && !Array.isArray(obj[key]) && obj[key]) {
if (
typeof obj[key] === "object" &&
!Array.isArray(obj[key]) &&
(obj[key] as object | null)
) {
transposedObj[transposedKey] = TransposeFolderData(obj[key] as Record<string, unknown>);
} else {
transposedObj[transposedKey] = obj[key];
@@ -236,9 +255,8 @@ export function CalculateLampDistribution(pbs: Array<PBScoreDocument>) {
const lampDist: Partial<Record<Lamps[IDStrings], integer>> = {};
for (const pb of pbs) {
if (lampDist[pb.scoreData.lamp]) {
// @ts-expect-error ???
lampDist[pb.scoreData.lamp]++;
if (lampDist[pb.scoreData.lamp] !== undefined) {
lampDist[pb.scoreData.lamp]!++;
} else {
lampDist[pb.scoreData.lamp] = 1;
}
@@ -251,9 +269,8 @@ export function CalculateGradeDistribution(pbs: Array<PBScoreDocument>) {
const gradeDist: Partial<Record<Grades[IDStrings], integer>> = {};
for (const pb of pbs) {
if (gradeDist[pb.scoreData.grade]) {
// @ts-expect-error ???
gradeDist[pb.scoreData.grade]++;
if (gradeDist[pb.scoreData.grade] !== undefined) {
gradeDist[pb.scoreData.grade]!++;
} else {
gradeDist[pb.scoreData.grade] = 1;
}
+4
View File
@@ -255,3 +255,7 @@ export function NotNullish<T>(maybeValue: T | null | undefined): T {
export function IsNullish<T>(maybeValue: T | null | undefined): maybeValue is null | undefined {
return maybeValue === null || maybeValue === undefined;
}
export function IsRecord(maybeRecord: unknown): maybeRecord is Record<string, unknown> {
return typeof maybeRecord === "object" && maybeRecord !== null;
}
+1 -1
View File
@@ -55,7 +55,7 @@ export function NaiveCSVParse(csvBuffer: Buffer, logger: KtLogger) {
const rawRows = [];
for (const [rowNumber, data] of Object.entries(csvData)) {
for (const [rowNumber, data] of Object.entries(csvData).slice(1)) {
// @security: This should probably be safetied from DOSing
const cells = data.split(",");
+3 -1
View File
@@ -5,7 +5,9 @@ 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}]`;
: ` | Received ${String(err.userVal)} [${
err.userVal === null ? "null" : typeof err.userVal
}]`;
return `${foreword}: ${err.keychain ?? "null"} | ${err.message}${receivedText}.`;
}
+23 -21
View File
@@ -297,30 +297,32 @@ export async function FindChartsOnPopularity(
}
>;
const scoreCounts = await db[scoreCollection].aggregate([
{
$match: { chartID: { $in: charts.map((e) => e.chartID) } },
},
{
$group: {
_id: "$chartID",
count: { $sum: 1 },
const scoreCounts: Array<{ _id: string; count: integer }> = await db[scoreCollection].aggregate(
[
{
$match: { chartID: { $in: charts.map((e) => e.chartID) } },
},
},
{
$sort: {
count: -1,
{
$group: {
_id: "$chartID",
count: { $sum: 1 },
},
},
},
{
$skip: skip,
},
{
$limit: limit,
},
]);
{
$sort: {
count: -1,
},
},
{
$skip: skip,
},
{
$limit: limit,
},
]
);
const scoreCountMap = new Map();
const scoreCountMap = new Map<string, integer>();
for (const sc of scoreCounts) {
scoreCountMap.set(sc._id, sc.count);
+2 -4
View File
@@ -1,6 +1,5 @@
import db from "external/mongo/db";
import type { Condition } from "mongodb";
import type { ScoreDocument, SessionDocument, SessionScoreInfo } from "tachi-common";
import type { ScoreDocument, SessionDocument } from "tachi-common";
/**
* Returns all the score documents inside a session.
@@ -18,7 +17,6 @@ export function GetScoresFromSession(session: SessionDocument) {
*/
export function GetSessionFromScore(score: ScoreDocument) {
return db.sessions.findOne({
// ??? another bug in monks types i think
scoreInfo: { scoreID: score.scoreID } as Condition<SessionScoreInfo>,
"scoreInfo.scoreID": score.scoreID,
});
}
+13
View File
@@ -27,6 +27,19 @@ declare module "express-session" {
}
}
declare module "express-serve-static-core" {
export interface Request {
// eslint-disable-next-line lines-around-comment
// KNOWN BUG IN TS-ESLINT.
/**
* This is a type-safe variant of "req.safeBody".
* "req.safeBody" is 'any' by default, which makes it exceptionally difficult
* to use in our codebase (due to the strict hollis rules.)
*/
safeBody: Record<string, unknown>;
}
}
export interface TachiSessionData {
user: PublicUserDocument;
settings: UserSettings;
+3 -3
View File
@@ -142,7 +142,7 @@ export function FormatUserDoc(userdoc: PublicUserDocument) {
export async function GetUsersRanking(stats: UserGameStats) {
const gptConfig = GetGamePTConfig(stats.game, stats.playtype);
const aggRes = await db["game-stats"].aggregate([
const aggRes: [{ _id: null; ranking: integer }] = await db["game-stats"].aggregate([
{
$match: {
game: stats.game,
@@ -170,7 +170,7 @@ export async function GetUsersRanking(stats: UserGameStats) {
},
]);
return (aggRes[0].ranking + 1) as integer;
return aggRes[0].ranking + 1;
}
export function GetUGPTPlaycount(userID: integer, game: Game, playtype: Playtype) {
@@ -257,7 +257,7 @@ export async function IsRequesterAdmin(request: APITokenDocument) {
return false;
}
if (!request.userID) {
if (request.userID === null) {
return false;
}