feat: initial cg support

This commit is contained in:
zkldi
2022-12-18 08:07:56 +00:00
parent 628d0ce9ca
commit 98ca458cff
28 changed files with 1437 additions and 31 deletions
@@ -133,7 +133,6 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
<ImportTypeInfoCard key="api/flo-iidx" importType="api/flo-iidx" />,
<ImportTypeInfoCard key="api/eag-iidx" importType="api/eag-iidx" />,
<ImportTypeInfoCard key="api/arc-iidx" importType="api/arc-iidx" />,
// <ImportTypeInfoCard key="file/mer-iidx" importType="file/mer-iidx" />,
<ImportTypeInfoCard key="file/solid-state-squad" importType="file/solid-state-squad" />,
<ImportTypeInfoCard key="file/pli-iidx-csv" importType="file/pli-iidx-csv" />
);
@@ -159,6 +158,8 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
/>,
<ImportTypeInfoCard key="api/flo-sdvx" importType="api/flo-sdvx" />,
<ImportTypeInfoCard key="api/eag-sdvx" importType="api/eag-sdvx" />,
<ImportTypeInfoCard key="api/cg-dev-sdvx" importType="api/cg-dev-sdvx" />,
<ImportTypeInfoCard key="api/cg-prod-sdvx" importType="api/cg-prod-sdvx" />,
<ImportTypeInfoCard key="api/min-sdvx" importType="api/min-sdvx" />,
<ImportTypeInfoCard key="api/arc-sdvx" importType="api/arc-sdvx" />
);
@@ -228,7 +229,9 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
desc={`Automatically upload Pop'n scores to ${TachiConfig.name}!`}
moreInfo="Yep, it's that simple."
key="Silent Hook"
/>
/>,
<ImportTypeInfoCard key="api/cg-dev-popn" importType="api/cg-dev-popn" />,
<ImportTypeInfoCard key="api/cg-prod-popn" importType="api/cg-prod-popn" />
);
} else if (game === "pms") {
Content.unshift(
@@ -249,7 +252,17 @@ function ImportInfoDisplayer({ game }: { game: Game }) {
key="WACCA Site Importer"
/>
);
}
} else if (game === "museca") {
Content.unshift(
<ImportTypeInfoCard key="api/cg-dev-museca" importType="api/cg-dev-museca" />,
<ImportTypeInfoCard key="api/cg-prod-museca" importType="api/cg-prod-museca" />
);
} // else if (game === "jubeat") {
// Content.unshift(
// <ImportTypeInfoCard key="api/cg-dev-jubeat" importType="api/cg-dev-jubeat" />,
// <ImportTypeInfoCard key="api/cg-prod-jubeat" importType="api/cg-prod-jubeat" />
// );
// }
return (
<>
@@ -265,7 +278,7 @@ function ImportTypeInfoCard({
importType,
}: {
importType: FileUploadImportTypes | APIImportTypes;
}) {
}): JSX.Element {
switch (importType) {
case "api/arc-sdvx":
return (
@@ -274,7 +287,7 @@ function ImportTypeInfoCard({
href="sdvx-arc"
desc="Pull your SDVX scores from the ARC Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons. ARC has a serious problem where it only stores one score per chart. This results in broken timestamps, and technically false score imports. I highly recommend using Barbatos instead, and only importing this once to synchronise things up."
key="ARC Integration"
key="sdvx-arc"
/>
);
case "api/arc-iidx":
@@ -284,7 +297,7 @@ function ImportTypeInfoCard({
href="iidx-arc"
desc="Pull your IIDX scores from the ARC Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons. ARC has a serious problem where it only stores one score per chart. This results in broken timestamps, and technically false score imports. I highly recommend using Fervidex instead, and only importing this once to synchronise things up."
key="ARC Integration"
key="iidx-arc"
/>
);
case "api/eag-iidx":
@@ -294,7 +307,7 @@ function ImportTypeInfoCard({
href="iidx-eag"
desc="Pull your IIDX scores from the EAG Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="EAG Integration"
key="iidx-eag"
/>
);
case "api/flo-iidx":
@@ -304,7 +317,7 @@ function ImportTypeInfoCard({
href="iidx-flo"
desc="Pull your IIDX scores from the FLO Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="FLO Integration"
key="iidx-flo"
/>
);
case "api/flo-sdvx":
@@ -314,7 +327,7 @@ function ImportTypeInfoCard({
href="sdvx-flo"
desc="Pull your SDVX scores from the FLO Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="FLO Integration"
key="sdvx-flo"
/>
);
case "api/eag-sdvx":
@@ -324,7 +337,7 @@ function ImportTypeInfoCard({
href="sdvx-eag"
desc="Pull your SDVX scores from the EAG Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="EAG Integration"
key="sdvx-eag"
/>
);
case "api/min-sdvx":
@@ -334,7 +347,61 @@ function ImportTypeInfoCard({
href="sdvx-min"
desc="Pull your SDVX scores from the MIN Network."
moreInfo="Note: All networks are reduced to their first three letters for anonymity reasons."
key="MIN Integration"
key="sdvx-min"
/>
);
case "api/cg-dev-sdvx":
return (
<ImportInfoCard
name="CG Dev Integration"
href="cg-dev-sdvx"
desc="Pull your SDVX scores from the CG Dev Network."
key="cg-dev-sdvx"
/>
);
case "api/cg-prod-sdvx":
return (
<ImportInfoCard
name="CG Prod Integration"
href="cg-prod-sdvx"
desc="Pull your SDVX scores from the CG Network."
key="cg-prod-sdvx"
/>
);
case "api/cg-dev-popn":
return (
<ImportInfoCard
name="CG Dev Integration"
href="cg-dev-popn"
desc="Pull your pop'n music scores from the CG Dev Network."
key="cg-dev-popn"
/>
);
case "api/cg-prod-popn":
return (
<ImportInfoCard
name="CG Prod Integration"
href="cg-prod-popn"
desc="Pull your pop'n music scores from the CG Network."
key="cg-prod-popn"
/>
);
case "api/cg-dev-museca":
return (
<ImportInfoCard
name="CG Dev Integration"
href="cg-dev-museca"
desc="Pull your MUSECA scores from the CG Dev Network."
key="cg-dev-museca"
/>
);
case "api/cg-prod-museca":
return (
<ImportInfoCard
name="CG Prod Integration"
href="cg-prod-museca"
desc="Pull your MUSECA scores from the CG Network."
key="cg-prod-museca"
/>
);
case "file/eamusement-iidx-csv":
@@ -405,6 +472,14 @@ function ImportTypeInfoCard({
key="PLI .csv"
/>
);
default:
// For some reason, the webpack tschecker thinks
// that the above switch isn't exhaustive. However, it is.
return (
<>
Err: Unknown importType <code>{importType}</code>
</>
);
}
}
@@ -1,4 +1,5 @@
import { APIFetchV1 } from "util/api";
import { isCardIDValid } from "util/misc";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import Loading from "components/util/Loading";
@@ -169,15 +170,3 @@ function FervidexForm({
</form>
);
}
function isCardIDValid(cardID: string) {
if (cardID.startsWith("E004")) {
return false;
}
if (cardID[0] === "C") {
return cardID.length === 13;
}
return cardID.length === 16;
}
+20
View File
@@ -24,6 +24,7 @@ import { mode } from "lib/config";
import React, { useContext } from "react";
import Switch from "react-bootstrap/esm/Switch";
import { Link, Redirect, Route } from "react-router-dom";
import CGIntegrationPage from "components/imports/CGIntegrationPage";
export default function ImportRoutes() {
const { user } = useContext(UserContext);
@@ -171,6 +172,25 @@ export default function ImportRoutes() {
game="sdvx"
/>
</Route>
<Route exact path="/dashboard/import/cg-dev-sdvx">
<CGIntegrationPage cgType="dev" game="sdvx" />
</Route>
<Route exact path="/dashboard/import/cg-dev-popn">
<CGIntegrationPage cgType="dev" game="popn" />
</Route>
<Route exact path="/dashboard/import/cg-dev-museca">
<CGIntegrationPage cgType="dev" game="museca" />
</Route>
<Route exact path="/dashboard/import/cg-prod-sdvx">
<CGIntegrationPage cgType="prod" game="sdvx" />
</Route>
<Route exact path="/dashboard/import/cg-prod-popn">
<CGIntegrationPage cgType="prod" game="popn" />
</Route>
<Route exact path="/dashboard/import/cg-prod-museca">
<CGIntegrationPage cgType="prod" game="museca" />
</Route>
<Route exact path="/dashboard/import/wacca-site">
<WACCASiteImportPage />
@@ -0,0 +1,195 @@
import { APIFetchV1 } from "util/api";
import { ErrorPage } from "app/pages/ErrorPage";
import useSetSubheader from "components/layout/header/useSetSubheader";
import ApiError from "components/util/ApiError";
import Divider from "components/util/Divider";
import FormInput from "components/util/FormInput";
import Loading from "components/util/Loading";
import useImport from "components/util/import/useImport";
import useApiQuery from "components/util/query/useApiQuery";
import { UserContext } from "context/UserContext";
import React, { useContext, useMemo, useReducer, useState } from "react";
import { Button, Col, Form, Row } from "react-bootstrap";
import { APIImportTypes, CGCardInfo, GetGameConfig } from "tachi-common";
import ImportStateRenderer from "./ImportStateRenderer";
interface Props {
cgType: "dev" | "prod";
game: "sdvx" | "popn" | "museca";
}
export default function CGIntegrationPage({ cgType, game }: Props) {
const gameConfig = GetGameConfig(game);
const cgName = cgType === "dev" ? "CG Dev" : "CG";
const [reload, shouldReloadCardInfo] = useReducer((x) => x + 1, 0);
useSetSubheader(["Import Scores", `${gameConfig.name} Sync (${cgName})`]);
const { user } = useContext(UserContext);
if (!user) {
return <ErrorPage statusCode={401} />;
}
const { data, error } = useApiQuery<CGCardInfo | null>(
`/users/${user.id}/integrations/cg/${cgType}`,
undefined,
[reload]
);
if (error) {
return <ApiError error={error} />;
}
// null is a valid response for this call, so be explicit with going to loading
if (data === undefined) {
return <Loading />;
}
if (data) {
return <CGImporter cgType={cgType} game={game} cardID={data.cardID} />;
} else {
return (
<CGNeedsIntegrate
cgType={cgType}
onSubmit={async (cardID, pin) => {
const res = await APIFetchV1(
`/users/${user.id}/integrations/cg/${cgType}`,
{
method: "PUT",
body: JSON.stringify({ cardID, pin }),
headers: {
"Content-Type": "application/json",
},
},
true,
true
);
if (res.success) {
shouldReloadCardInfo();
}
}}
/>
);
}
}
function CGImporter({ cgType, game, cardID }: Pick<Props, "cgType" | "game"> & { cardID: string }) {
const importType: APIImportTypes = `api/cg-${cgType}-${game}`;
const cgName = cgType === "dev" ? "CG Dev" : "CG";
const { importState, runImport } = useImport("/import/from-api", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
importType,
}),
});
return (
<div>
<h2 className="text-center mb-4">
Importing scores from {cgName} card{" "}
<code>{cardID.match(/.{1,4}/gu)?.join(" ")}</code>.
</h2>
<Divider />
<div className="d-flex w-100 justify-content-center">
<Button
className="mx-auto"
variant="primary"
onClick={() => runImport()}
disabled={
importState.state === "waiting_init" ||
importState.state === "waiting_processing"
}
>
{importState.state === "waiting_init" ||
importState.state === "waiting_processing"
? "Syncing..."
: "Click to Sync!"}
</Button>
</div>
<Divider />
<div>
Play on {cgName} a lot? You can synchronise your scores straight from the discord by
typing{" "}
<code>
/sync {cgType === "dev" ? "CG-DEV" : "CG"} {game}
</code>
!
</div>
<Divider />
<ImportStateRenderer state={importState} />
</div>
);
}
function CGNeedsIntegrate({
cgType,
onSubmit,
}: Pick<Props, "cgType"> & {
onSubmit: (cardID: string, pin: string) => Promise<void>;
}) {
const cgName = cgType === "dev" ? "CG Dev" : "CG";
const [cardID, setCardID] = useState("");
const [pin, setPin] = useState("");
// strip any whitespace the user feels like entering
const realCardID = useMemo(() => cardID.replace(/\s+/gu, ""), [cardID]);
const shouldDisable = useMemo(() => {
// yes i could turn this into a boolean with !
// but have you *seen* how ugly that is?
if (/^[0-9]{4}$/u.exec(pin) && /^[a-zA-Z0-9]{16}$/u.exec(realCardID)) {
return false;
}
return true;
}, [pin, realCardID]);
return (
<div>
<h3 className="text-center mb-4">We need to know what card you use on {cgName}!</h3>
<Row>
<Col xs={12} lg={8} className="offset-lg-2">
<FormInput fieldName="Card ID" setValue={setCardID} value={cardID} />
<Form.Label>
This is the card ID that's displayed in game. It should be 16 characters
long.
<br />
{cardID.length > 0 && !/^[a-zA-Z0-9]{16}$/u.exec(realCardID) ? (
<span className="text-danger">
Invalid Card ID. This should be 16 alphanumeric characters.
</span>
) : (
cardID.length > 0 && <span className="text-success">Looking good!</span>
)}
</Form.Label>
<br />
<FormInput fieldName="PIN" setValue={setPin} value={pin} />
<Form.Label>What PIN do you use to card in to {cgName}?</Form.Label>
<br />
{pin.length > 0 && !/^[0-9]{4}$/u.exec(pin) ? (
<span className="text-danger">Invalid PIN. This should be 4 digits.</span>
) : (
pin.length > 0 && <span className="text-success">Looking good!</span>
)}
<Divider />
<div className="w-100 d-flex justify-content-center">
<Button disabled={shouldDisable} onClick={() => onSubmit(realCardID, pin)}>
Submit Card ID
</Button>
</div>
</Col>
</Row>
</div>
);
}
+3
View File
@@ -8,12 +8,14 @@ export default function FormInput({
setValue,
placeholder,
as,
type,
}: {
fieldName: string;
value: string;
setValue: SetState<string>;
placeholder?: string;
as?: ElementType;
type?: string;
}) {
return (
<InputGroup>
@@ -25,6 +27,7 @@ export default function FormInput({
placeholder={placeholder}
value={value}
onChange={(e) => setValue(e.target.value)}
type={type}
/>
</InputGroup>
);
+12
View File
@@ -582,3 +582,15 @@ export function HumanisedJoinArray(arr: Array<string>, lastJoiner = "or") {
return `${arr.slice(0, arr.length - 1).join(", ")} ${lastJoiner} ${arr[arr.length - 1]!}`;
}
export function isCardIDValid(cardID: string) {
if (cardID.startsWith("E004")) {
return false;
}
if (cardID[0] === "C") {
return cardID.length === 13;
}
return cardID.length === 16;
}
+6
View File
@@ -37,6 +37,12 @@ export const apiImportTypes: Array<APIImportTypes> = [
"api/flo-iidx",
"api/flo-sdvx",
"api/min-sdvx",
"api/cg-dev-museca",
"api/cg-dev-popn",
"api/cg-dev-sdvx",
"api/cg-prod-museca",
"api/cg-prod-popn",
"api/cg-prod-sdvx",
];
export const allImportTypes: Array<ImportTypes> = [
+15 -1
View File
@@ -1282,7 +1282,12 @@ export type APIImportTypes =
| "api/eag-sdvx"
| "api/flo-iidx"
| "api/flo-sdvx"
| "api/min-sdvx";
| "api/min-sdvx"
// cg has dev and prod supported
// with four games.
// this typescript feature for stringliteral expansion is kinda neat.
| `api/cg-${"dev" | "prod"}-${"museca" | "popn" | "sdvx"}`;
export type IRImportTypes =
| "ir/barbatos"
@@ -1363,6 +1368,15 @@ export interface KaiAuthDocument {
service: "EAG" | "FLO" | "MIN";
}
export interface CGCardInfo {
userID: integer;
service: "dev" | "prod";
cardID: string;
// are we gonna do maths on it? no. it's a string. don't bother me.
pin: string;
}
/**
* Used to resolve beatoraja IR courses.
*/
+6
View File
@@ -68,6 +68,12 @@
"api/flo-iidx",
"api/flo-sdvx",
"api/min-sdvx",
"api/cg-dev-sdvx",
"api/cg-dev-popn",
"api/cg-dev-museca",
"api/cg-prod-sdvx",
"api/cg-prod-popn",
"api/cg-prod-museca",
],
},
LOGGER_CONFIG: {
+3
View File
@@ -48,6 +48,7 @@ import type {
UserSettings,
KsHookSettingsDocument,
ImportTrackerDocument as ImportTrackerDocument,
CGCardInfo,
} from "tachi-common";
import type { MigrationDocument, PrivateUserInfoDocument } from "utils/types";
@@ -153,6 +154,8 @@ const db = {
counters: monkDB.get<CounterDocument>("counters"),
"game-stats": monkDB.get<UserGameStats>("game-stats"),
"kai-auth-tokens": monkDB.get<KaiAuthDocument>("kai-auth-tokens"),
"cg-card-info": monkDB.get<CGCardInfo>("cg-card-info"),
"bms-course-lookup": monkDB.get<BMSCourseDocument>("bms-course-lookup"),
"api-tokens": monkDB.get<APITokenDocument>("api-tokens"),
"orphan-scores": monkDB.get<OrphanScoreDocument>("orphan-scores"),
+6
View File
@@ -39,6 +39,12 @@ export const DatabaseSchemas: Record<Databases, SchemaValidatorFunction> = {
refreshToken: "string",
service: p.isIn("FLO", "EAG", "MIN"),
}),
"cg-card-info": prSchemaify({
userID: p.isPositiveNonZeroInteger,
service: p.isIn("dev", "prod"),
cardID: "string",
pin: "string",
}),
"oauth2-auth-codes": prSchemaify({
code: "string",
userID: p.isPositiveNonZeroInteger,
@@ -1,4 +1,9 @@
import { GenericCalculatePercent, GetGradeFromPercent, ValidatePercent } from "./score-utils";
import {
GenericCalculatePercent,
GetGradeFromPercent,
JubeatGetMusicRate,
ValidatePercent,
} from "./score-utils";
import t from "tap";
import { isApproximately } from "test-utils/asserts";
import { Testing511SPA } from "test-utils/test-data";
@@ -144,3 +149,20 @@ t.test("#ValidatePercent", (t) => {
t.end();
});
t.test("#JubeatGetMusicRate", (t) => {
t.equal(
JubeatGetMusicRate(423, 136, 41, 14, 5, 619, false),
73.0,
"Should correctly calculate jubeat music rate."
);
t.equal(JubeatGetMusicRate(418, 158, 90, 54, 92, 818, false), 73.0);
t.equal(
JubeatGetMusicRate(423, 136, 41, 14, 5, 619, true),
73.0 * 1.2,
"Should correctly calculate hard mode jubeat music rate."
);
t.end();
});
@@ -1,7 +1,7 @@
import { InternalFailure, InvalidScoreFailure } from "./converter-failures";
import CreateLogCtx from "lib/logger/logger";
import { ESDCore, GetGamePTConfig } from "tachi-common";
import { IsNullish, NotNullish } from "utils/misc";
import { FloorToNDP, IsNullish, NotNullish } from "utils/misc";
import type {
ChartDocument,
Game,
@@ -9,6 +9,8 @@ import type {
Grades,
IDStrings,
Playtype,
Lamps,
integer,
} from "tachi-common";
const logger = CreateLogCtx(__filename);
@@ -46,7 +48,7 @@ export function GetGradeFromPercent<I extends IDStrings = IDStrings>(
}
/**
* A Generic function for calculating a percent from a given score on
* A generic function for calculating a percent from a given score on
* a given game.
*/
export function GenericCalculatePercent(game: Game, score: number, chart?: ChartDocument): number {
@@ -93,6 +95,7 @@ export function GenericCalculatePercent(game: Game, score: number, chart?: Chart
}
case "jubeat":
throw new Error(`Cannot calculate percent for jubeat. Music Rate must be provided.`);
case "gitadora":
case "itg":
return score;
@@ -183,6 +186,22 @@ export function JubeatGetGrade(score: number): Grades["jubeat:Single"] {
return "E";
}
export function JubeatGetMusicRate(
perfect: integer,
great: integer,
good: integer,
poor: integer,
miss: integer,
chartMaxCombo: integer,
hardMode: boolean
) {
const multiplier = hardMode ? 120 : 100;
const rawMusicRate = ((perfect + 0.2 * great + 0.05 * good) / chartMaxCombo) * multiplier;
return FloorToNDP(rawMusicRate, 1);
}
/**
* Calculates the ESD for a given game + percent combo. This function returns
* null if the game does not support support ESD.
@@ -218,3 +237,34 @@ export function ParseDateFromString(str: string | null | undefined): number | nu
return date;
}
/**
* Turn a museca score into its lamp. Note that we disagree with the game on what
* constitutes a clear -- instead, 800k is marked as the minimum point for a clear.
*
* Museca actually handles clears differently with a bunch of grafica nonsense,
* but nobody actually cares about it, so...
*/
export function MusecaGetLamp(score: integer, missCount: integer): Lamps["museca:Single"] {
if (score === 1_000_000) {
return "PERFECT CONNECT ALL";
} else if (missCount === 0) {
return "CONNECT ALL";
} else if (score >= 800_000) {
return "CLEAR";
}
return "FAILED";
}
export function JubeatGetLamp(score: integer, missCount: integer): Lamps["jubeat:Single"] {
if (score === 1_000_000) {
return "EXCELLENT";
} else if (missCount === 0) {
return "FULL COMBO";
} else if (score >= 700_000) {
return "CLEAR";
}
return "FAILED";
}
@@ -0,0 +1,103 @@
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "lib/score-import/framework/common/converter-failures";
import {
GenericGetGradeAndPercent,
MusecaGetLamp,
ParseDateFromString,
} from "lib/score-import/framework/common/score-utils";
import { FindChartOnInGameIDVersion } from "utils/queries/charts";
import { FindSongOnID } from "utils/queries/songs";
import type { ConverterFunction } from "../../types";
import type { CGContext, CGMusecaScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
import type { Difficulties, GPTSupportedVersions } from "tachi-common";
export const ConverterAPICGMuseca: ConverterFunction<CGMusecaScore, CGContext> = async (
data,
context,
importType,
logger
) => {
const difficulty = ConvertDifficulty(data.difficulty);
const version = ConvertVersion(data.version);
const chart = await FindChartOnInGameIDVersion(
"museca",
data.internalId,
"Single",
difficulty,
version
);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`,
importType,
data,
context
);
}
const song = await FindSongOnID("museca", chart.songID);
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (museca).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (museca).`);
}
const lamp = MusecaGetLamp(data.score, data.error);
const { percent, grade } = GenericGetGradeAndPercent("museca", data.score, chart);
const timeAchieved = ParseDateFromString(data.dateTime);
const dryScore: DryScore<"museca:Single"> = {
comment: null,
game: "museca",
importType,
timeAchieved,
service: context.service,
scoreData: {
grade,
percent,
score: data.score,
lamp,
judgements: {
critical: data.critical,
near: data.near,
miss: data.error,
},
hitMeta: {
maxCombo: data.maxChain,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
};
function ConvertDifficulty(diff: number): Difficulties["museca:Single"] {
switch (diff) {
case 0:
return "Green";
case 1:
return "Yellow";
case 2:
return "Red";
}
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
}
function ConvertVersion(ver: number): GPTSupportedVersions["museca:Single"] {
switch (ver) {
case 1:
return "1.5-b";
}
throw new InvalidScoreFailure(`Unknown/Unsupported Game Version ${ver}.`);
}
@@ -0,0 +1,150 @@
import { FetchCGScores } from "./traverse-api";
import { CGGameToTachiGame } from "./util";
import db from "external/mongo/db";
import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error";
import fetch from "node-fetch";
import p from "prudence";
import { FormatPrError } from "tachi-common";
import type { ParserFunctionReturns } from "../types";
import type {
CGContext,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
CGSDVXScore,
CGServices,
CGSupportedGames,
} from "./types";
import type { KtLogger } from "lib/logger/logger";
import type { PrudenceSchema } from "prudence";
import type { integer } from "tachi-common";
const PR_CG_JUBEAT = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
version: p.isPositiveInteger,
// unused
clearFlag: p.any,
score: p.isBoundedInteger(0, 1_000_000),
hardMode: "boolean",
perfectCount: p.isPositiveInteger,
greatCount: p.isPositiveInteger,
goodCount: p.isPositiveInteger,
poorCount: p.isPositiveInteger,
missCount: p.isPositiveInteger,
dateTime: "string",
};
const PR_CG_SDVX = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
version: p.isPositiveInteger,
score: p.isBoundedInteger(0, 1_000_000),
exScore: p.isPositiveInteger,
clearType: p.isPositiveInteger,
// unused
scoreGrade: p.any,
maxChain: p.isPositiveInteger,
critical: p.isPositiveInteger,
near: p.isPositiveInteger,
error: p.isPositiveInteger,
dateTime: "string",
};
const PR_CG_MUSECA = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
version: p.isPositiveInteger,
score: p.isBoundedInteger(0, 1_000_000),
// unused
clearType: p.any,
scoreGrade: p.any,
maxChain: p.isPositiveInteger,
critical: p.isPositiveInteger,
near: p.isPositiveInteger,
error: p.isPositiveInteger,
dateTime: "string",
};
const PR_CG_POPN = {
internalId: p.isPositiveInteger,
difficulty: p.isPositiveInteger,
version: p.isPositiveInteger,
clearFlag: p.isPositiveInteger,
score: p.isBoundedInteger(0, 100_000),
coolCount: p.isPositiveInteger,
greatCount: p.isPositiveInteger,
goodCount: p.isPositiveInteger,
badCount: p.isPositiveInteger,
dateTime: "string",
};
// given a CG game, what should the returned data look like?
const CG_SCHEMAS: Record<CGSupportedGames, PrudenceSchema> = {
jb: PR_CG_JUBEAT,
msc: PR_CG_MUSECA,
sdvx: PR_CG_SDVX,
popn: PR_CG_POPN,
};
/**
* Create a CG parser for this supported game. Since all CG parsing code is effectively
* identical, this basically just placeholders cgGame and service.
*/
export function CreateCGParser<T>(cgGame: CGSupportedGames, service: CGServices) {
return async (
userID: integer,
logger: KtLogger
): Promise<ParserFunctionReturns<T, CGContext>> => {
const cardInfo = await db["cg-card-info"].findOne({
userID,
service,
});
if (!cardInfo) {
throw new ScoreImportFatalError(
401,
`This user has no card info set up for this service.`
);
}
const scores = await FetchCGScores(service, cardInfo, cgGame, logger, fetch);
const SCHEMA = CG_SCHEMAS[cgGame];
// check that this data is in the structure we expected
const err = p({ data: scores }, { data: [SCHEMA] });
if (err) {
throw new ScoreImportFatalError(400, FormatPrError(err, `Invalid CG ${cgGame} Score.`));
}
return {
context: {
service,
userID: cardInfo.userID,
},
game: CGGameToTachiGame(cgGame),
iterable: scores as Array<T>,
classHandler: null,
};
};
}
export const ParseCGDevMuseca = CreateCGParser<CGMusecaScore>("msc", "dev");
export const ParseCGDevSDVX = CreateCGParser<CGSDVXScore>("sdvx", "dev");
export const ParseCGDevJubeat = CreateCGParser<CGJubeatScore>("jb", "dev");
export const ParseCGDevPopn = CreateCGParser<CGPopnScore>("popn", "dev");
export const ParseCGProdMuseca = CreateCGParser<CGMusecaScore>("msc", "prod");
export const ParseCGProdSDVX = CreateCGParser<CGSDVXScore>("sdvx", "prod");
export const ParseCGProdJubeat = CreateCGParser<CGJubeatScore>("jb", "prod");
export const ParseCGProdPopn = CreateCGParser<CGPopnScore>("popn", "prod");
@@ -0,0 +1,167 @@
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "lib/score-import/framework/common/converter-failures";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
} from "lib/score-import/framework/common/score-utils";
import { FindChartOnInGameIDVersion } from "utils/queries/charts";
import { FindSongOnID } from "utils/queries/songs";
import type { ConverterFunction } from "../../types";
import type { CGContext, CGPopnScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
import type {
Difficulties,
GPTSupportedVersions,
HitMetaLookup,
Lamps,
integer,
} from "tachi-common";
export const ConverterAPICGPopn: ConverterFunction<CGPopnScore, CGContext> = async (
data,
context,
importType,
logger
) => {
const difficulty = ConvertDifficulty(data.difficulty);
const version = ConvertVersion(data.version);
const chart = await FindChartOnInGameIDVersion(
"popn",
data.internalId,
"9B",
difficulty,
version
);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`,
importType,
data,
context
);
}
const song = await FindSongOnID("popn", chart.songID);
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (popn).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (popn).`);
}
const lamp = GetLamp(data.clearFlag);
const specificClearType = GetSpecificClearMedal(data.clearFlag);
const gradeAndPercent = GenericGetGradeAndPercent("popn", data.score, chart);
const percent = gradeAndPercent.percent; // ugly declarations because one is const
// and the other isn't...
let grade = gradeAndPercent.grade;
// TEMP HACK: TODO MOVE THIS TO COMMON SOMEHOW
// pop'n scores are capped at an A rank if they're fails.
if (lamp === "FAILED" && percent >= 90) {
grade = "A";
}
const timeAchieved = ParseDateFromString(data.dateTime);
const dryScore: DryScore<"popn:9B"> = {
comment: null,
game: "popn",
importType,
timeAchieved,
service: context.service,
scoreData: {
grade,
percent,
score: data.score,
lamp,
judgements: {
cool: data.coolCount,
great: data.greatCount,
good: data.goodCount,
bad: data.badCount,
},
hitMeta: {
specificClearType,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
};
function ConvertDifficulty(diff: number): Difficulties["popn:9B"] {
switch (diff) {
case 0:
return "Easy";
case 1:
return "Normal";
case 2:
return "Hyper";
case 3:
return "EX";
}
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
}
function ConvertVersion(ver: number): GPTSupportedVersions["popn:9B"] {
switch (ver) {
case 26:
return "kaimei";
case 25:
return "peace";
}
throw new InvalidScoreFailure(`Unknown/Unsupported Game Version ${ver}.`);
}
function GetLamp(clearFlag: integer): Lamps["popn:9B"] {
if (clearFlag === 11) {
return "PERFECT";
} else if (clearFlag >= 8) {
return "FULL COMBO";
} else if (clearFlag >= 5) {
return "CLEAR";
} else if (clearFlag === 4) {
return "EASY CLEAR";
}
return "FAILED";
}
function GetSpecificClearMedal(clearFlag: integer): HitMetaLookup["popn:9B"]["specificClearType"] {
switch (clearFlag) {
case 1:
return "failedCircle";
case 2:
return "failedDiamond";
case 3:
return "failedStar";
case 4:
return "easyClear";
case 5:
return "clearCircle";
case 6:
return "clearDiamond";
case 7:
return "clearStar";
case 8:
return "fullComboCircle";
case 9:
return "fullComboDiamond";
case 10:
return "fullComboStar";
case 11:
return "perfect";
}
// no idea.
return null;
}
@@ -0,0 +1,149 @@
import {
InternalFailure,
InvalidScoreFailure,
KTDataNotFoundFailure,
} from "lib/score-import/framework/common/converter-failures";
import {
GenericGetGradeAndPercent,
ParseDateFromString,
} from "lib/score-import/framework/common/score-utils";
import { FindSDVXChartOnInGameIDVersion } from "utils/queries/charts";
import { FindSongOnID } from "utils/queries/songs";
import type { ConverterFunction } from "../../types";
import type { CGContext, CGSDVXScore } from "../types";
import type { DryScore } from "lib/score-import/framework/common/types";
import type { GPTSupportedVersions, Lamps } from "tachi-common";
export const ConverterAPICGSDVX: ConverterFunction<CGSDVXScore, CGContext> = async (
data,
context,
importType,
logger
) => {
const difficulty = ConvertDifficulty(data.difficulty);
const version = ConvertVersion(data.version);
const chart = await FindSDVXChartOnInGameIDVersion(data.internalId, difficulty, version);
if (!chart) {
throw new KTDataNotFoundFailure(
`Could not find chart with songID ${data.internalId} (${difficulty} - Version ${version})`,
importType,
data,
context
);
}
const song = await FindSongOnID("sdvx", chart.songID);
if (!song) {
logger.severe(`Song-Chart desync with song ID ${chart.songID} (sdvx).`);
throw new InternalFailure(`Song-Chart desync with song ID ${chart.songID} (sdvx).`);
}
const lamp = ConvertCGSDVXLamp(version, data.clearType);
const { percent, grade } = GenericGetGradeAndPercent("sdvx", data.score, chart);
const timeAchieved = ParseDateFromString(data.dateTime);
const dryScore: DryScore<"sdvx:Single"> = {
comment: null,
game: "sdvx",
importType,
timeAchieved,
service: context.service,
scoreData: {
grade,
percent,
score: data.score,
lamp,
judgements: {
critical: data.critical,
near: data.near,
miss: data.error,
},
hitMeta: {
maxCombo: data.maxChain,
},
},
scoreMeta: {},
};
return { song, chart, dryScore };
};
function ConvertDifficulty(diff: number) {
switch (diff) {
case 0:
return "NOV";
case 1:
return "ADV";
case 2:
return "EXH";
case 3:
return "ANY_INF";
case 4:
return "MXM";
}
throw new InvalidScoreFailure(`Invalid difficulty of ${diff} - Could not convert.`);
}
function ConvertVersion(ver: number): GPTSupportedVersions["sdvx:Single"] {
switch (ver) {
case 1:
return "booth";
case 2:
return "inf";
case 3:
return "gw";
case 4:
return "heaven";
case 5:
return "vivid";
case 6:
return "exceed";
}
throw new InvalidScoreFailure(`Unknown Game Version ${ver}.`);
}
/**
* Convert CG's clearType enum into a Tachi lamp. Note that what numbers mean what are
* dependent on what version of the game we're listening for.
*/
function ConvertCGSDVXLamp(
version: GPTSupportedVersions["sdvx:Single"],
clearType: number
): Lamps["sdvx:Single"] {
switch (clearType) {
case 0:
return "FAILED";
case 1:
return "CLEAR";
}
// this version doesn't have excessive clears, so the ints are off by one.
if (version === "booth") {
switch (clearType) {
case 2:
return "ULTIMATE CHAIN";
case 3:
return "PERFECT ULTIMATE CHAIN";
}
} else {
switch (clearType) {
case 2:
return "EXCESSIVE CLEAR";
case 3:
return "ULTIMATE CHAIN";
case 4:
return "PERFECT ULTIMATE CHAIN";
}
}
throw new InvalidScoreFailure(
`Invalid lamp of ${clearType} for ${version} - Could not convert.`
);
}
@@ -0,0 +1,109 @@
import ScoreImportFatalError from "lib/score-import/framework/score-importing/score-import-error";
import { ServerConfig } from "lib/setup/config";
import p from "prudence";
import nodeFetch from "utils/fetch";
import { URLSearchParams } from "url";
import type { CGErrorResponse, CGScoresResponse, CGServices, CGSupportedGames } from "./types";
import type { KtLogger } from "lib/logger/logger";
import type { PrudenceSchema } from "prudence";
import type { CGCardInfo } from "tachi-common";
import type { NodeFetch } from "utils/fetch";
const PR_CG_RESPONSE: PrudenceSchema = {
success: "boolean",
data: {
profile: {
id: "string",
name: "string",
},
// validated elsewhere
scores: [p.any],
},
};
const PR_CG_ERR_RESPONSE: PrudenceSchema = {
success: "boolean",
message: "string",
};
/**
* Fetch this info from CG. **This function does not validate the content of scores**,
* instead, that should be handled by the parser.
*/
export async function FetchCGScores(
service: CGServices,
cardInfo: CGCardInfo,
game: CGSupportedGames,
logger: KtLogger,
fetch: NodeFetch = nodeFetch
): Promise<Array<unknown>> {
const url = GetCGUrl(service, cardInfo, game);
let validatedRes: CGErrorResponse | CGScoresResponse<unknown>;
try {
const res: unknown = await fetch(url).then((r) => r.json());
const prErr = p({ res }, { res: p.or(PR_CG_RESPONSE, PR_CG_ERR_RESPONSE) });
if (prErr) {
logger.error(`Got unexpected data from CG.`, { res });
throw new Error(`Got unexpected data from CG.`);
}
validatedRes = res as CGErrorResponse | CGScoresResponse<unknown>;
} catch (err) {
logger.error(`Received invalid response from ${url}.`, { err });
throw new ScoreImportFatalError(
500,
`Received invalid response from their API. Are they down?`
);
}
if (validatedRes.success) {
return validatedRes.data.scores;
}
throw new ScoreImportFatalError(
400,
`Failed to fetch data from CG. Error Message: ${validatedRes.message}.`
);
}
function GetCGConf(service: CGServices) {
switch (service) {
case "dev": {
if (!ServerConfig.CG_DEV_CONFIG) {
throw new Error(
`No CG_DEV_CONFIG is available, yet a request to cg-dev was attempted?`
);
}
return ServerConfig.CG_DEV_CONFIG;
}
case "prod": {
if (!ServerConfig.CG_PROD_CONFIG) {
throw new Error(
`No CG_PROD_CONFIG is available, yet a request to cg-prod was attempted?`
);
}
return ServerConfig.CG_PROD_CONFIG;
}
}
}
function GetCGUrl(service: CGServices, cardInfo: CGCardInfo, game: CGSupportedGames) {
const cgConf = GetCGConf(service);
const params = new URLSearchParams();
params.set("api_key", cgConf.API_KEY);
params.set("card_id", cardInfo.cardID);
params.set("pin", cardInfo.pin);
return `${cgConf.URL}/${game}-scores?${params.toString()}`;
}
@@ -0,0 +1,89 @@
import type { integer } from "tachi-common";
export type CGSupportedGames =
| "jb" // jubeat
| "msc" // museca
| "popn"
| "sdvx";
export type CGServices = "dev" | "prod";
export interface CGScoresResponse<T> {
success: true;
data: {
profile: {
id: string;
name: string;
};
scores: Array<T>;
};
}
export interface CGErrorResponse {
success: false;
message: string;
}
export interface CGMusecaScore {
internalId: integer;
difficulty: integer;
version: integer;
score: integer;
clearType: unknown; // we don't use this
scoreGrade: unknown; // don't care, don't use
maxChain: integer;
critical: integer;
near: integer;
error: integer;
dateTime: string;
}
export interface CGPopnScore {
internalId: integer;
difficulty: integer;
version: integer;
clearFlag: integer;
score: integer;
coolCount: integer;
greatCount: integer;
goodCount: integer;
badCount: integer;
dateTime: string;
}
export interface CGSDVXScore {
internalId: integer;
difficulty: integer;
version: integer;
score: integer;
exScore: integer;
clearType: integer; // what this means depends on what version of the game
// we're looking at.
scoreGrade: unknown; // unused, as we derive it
maxChain: integer;
critical: integer;
near: integer;
error: integer;
dateTime: string;
}
export interface CGJubeatScore {
internalId: integer;
difficulty: integer;
version: integer;
clearFlag: unknown; // Weird bitwise field, unused.
score: integer;
hardMode: boolean;
perfectCount: integer;
greatCount: integer;
goodCount: integer;
poorCount: integer;
missCount: integer;
dateTime: string;
}
export interface CGContext {
service: "dev" | "prod";
userID: integer;
}
@@ -0,0 +1,14 @@
import type { CGSupportedGames } from "./types";
import type { Game } from "tachi-common";
export function CGGameToTachiGame(cgGame: CGSupportedGames): Game {
switch (cgGame) {
case "jb":
return "jubeat";
case "msc":
return "museca";
case "popn":
case "sdvx":
return cgGame;
}
}
@@ -79,10 +79,10 @@ export async function* TraverseKaiAPI(
},
});
} catch (err) {
logger.error(`received invalid response from ${url}.`, { err });
logger.error(`Received invalid response from ${url}.`, { err });
throw new ScoreImportFatalError(
500,
`received invalid response from their API. Are they down?`
`Received invalid response from their API. Are they down?`
);
}
@@ -12,6 +12,13 @@ import type { KsHookSV6CStaticScore } from "../ir/kshook-sv6c-static/types";
import type { KsHookSV6CContext, KsHookSV6CScore } from "../ir/kshook-sv6c/types";
import type { LR2HookContext, LR2HookScore } from "../ir/lr2hook/types";
import type { IRUSCContext } from "../ir/usc/types";
import type {
CGContext,
CGJubeatScore,
CGMusecaScore,
CGPopnScore,
CGSDVXScore,
} from "./api-cg/types";
import type { KaiContext } from "./api-kai/types";
import type { BatchManualContext } from "./batch-manual/types";
import type { IIDXEamusementCSVContext, IIDXEamusementCSVData } from "./eamusement-iidx-csv/types";
@@ -55,6 +62,18 @@ export interface ImportTypeDataMap {
"api/min-sdvx": unknown;
"api/eag-iidx": unknown;
"api/eag-sdvx": unknown;
"api/cg-dev-sdvx": CGSDVXScore;
"api/cg-dev-museca": CGMusecaScore;
"api/cg-dev-popn": CGPopnScore;
// temporarily disabled as no musicRate is provided.
// "api/cg-dev-jubeat": CGJubeatScore;
// "api/cg-prod-jubeat": CGJubeatScore;
"api/cg-prod-sdvx": CGSDVXScore;
"api/cg-prod-museca": CGMusecaScore;
"api/cg-prod-popn": CGPopnScore;
}
export interface ImportTypeContextMap {
@@ -82,6 +101,15 @@ export interface ImportTypeContextMap {
"api/min-sdvx": KaiContext;
"api/eag-iidx": KaiContext;
"api/eag-sdvx": KaiContext;
"api/cg-dev-jubeat": CGContext;
"api/cg-dev-sdvx": CGContext;
"api/cg-dev-museca": CGContext;
"api/cg-dev-popn": CGContext;
"api/cg-prod-jubeat": CGContext;
"api/cg-prod-sdvx": CGContext;
"api/cg-prod-museca": CGContext;
"api/cg-prod-popn": CGContext;
}
export interface OrphanScoreDocument<T extends ImportTypes = ImportTypes> extends MongoDBDocument {
@@ -123,4 +151,4 @@ export interface ParserFunctionReturns<D, C> {
export type ParserFunction<D, C, A extends Array<unknown>> = (
...args: A
) => ParserFunctionReturns<D, C>;
) => ParserFunctionReturns<D, C> | Promise<ParserFunctionReturns<D, C>>;
@@ -1,5 +1,8 @@
import { ConvertAPIArcIIDX } from "./api/arc-iidx/converter";
import { ConvertAPIArcSDVX } from "./api/arc-sdvx/converter";
import { ConverterAPICGMuseca } from "./common/api-cg/museca/converter";
import { ConverterAPICGPopn } from "./common/api-cg/popn/converter";
import { ConverterAPICGSDVX } from "./common/api-cg/sdvx/converter";
import { ConvertAPIKaiIIDX } from "./common/api-kai/iidx/converter";
import { ConvertAPIKaiSDVX } from "./common/api-kai/sdvx/converter";
import { ConverterBatchManual } from "./common/batch-manual/converter";
@@ -47,4 +50,14 @@ export const Converters: ConverterMap = {
"ir/kshook-sv6c": ConverterIRKsHookSV6C,
"ir/kshook-sv6c-static": ConverterKsHookSV6CStatic,
"ir/lr2hook": ConverterLR2Hook,
"api/cg-dev-sdvx": ConverterAPICGSDVX,
"api/cg-dev-museca": ConverterAPICGMuseca,
"api/cg-dev-popn": ConverterAPICGPopn,
"api/cg-prod-sdvx": ConverterAPICGSDVX,
"api/cg-prod-museca": ConverterAPICGMuseca,
"api/cg-prod-popn": ConverterAPICGPopn,
// "api/cg-dev-jubeat": ConverterAPICGJubeat,
// "api/cg-prod-jubeat": ConverterAPICGJubeat,
};
@@ -5,6 +5,16 @@ import { ParseEagSDVX } from "./api/eag-sdvx/parser";
import { ParseFloIIDX } from "./api/flo-iidx/parser";
import { ParseFloSDVX } from "./api/flo-sdvx/parser";
import { ParseMinSDVX } from "./api/min-sdvx/parser";
import {
ParseCGDevJubeat,
ParseCGDevMuseca,
ParseCGDevPopn,
ParseCGDevSDVX,
ParseCGProdJubeat,
ParseCGProdMuseca,
ParseCGProdPopn,
ParseCGProdSDVX,
} from "./common/api-cg/parsers";
import ParseBatchManual from "./file/batch-manual/parser";
import ParseEamusementIIDXCSV from "./file/eamusement-iidx-csv/parser";
import ParseEamusementSDVXCSV from "./file/eamusement-sdvx-csv/parser";
@@ -37,6 +47,18 @@ export const Parsers = {
"api/arc-iidx": ParseArcIIDX,
"api/arc-sdvx": ParseArcSDVX,
"api/cg-dev-sdvx": ParseCGDevSDVX,
"api/cg-dev-popn": ParseCGDevPopn,
"api/cg-dev-museca": ParseCGDevMuseca,
"api/cg-prod-sdvx": ParseCGProdSDVX,
// temporarily disabled as no musicRate is provided.
// "api/cg-dev-jubeat": ParseCGDevJubeat,
// "api/cg-prod-jubeat": ParseCGProdJubeat,
"api/cg-prod-popn": ParseCGProdPopn,
"api/cg-prod-museca": ParseCGProdMuseca,
"ir/barbatos": ParseBarbatosSingle,
"ir/beatoraja": ParseBeatorajaSingle,
"ir/fervidex": ParseFervidexSingle,
@@ -46,4 +68,6 @@ export const Parsers = {
"ir/kshook-sv6c": ParseKsHookSV6C,
"ir/kshook-sv6c-static": ParseKsHookSV6CStatic,
"ir/lr2hook": ParseLR2Hook,
};
}; /* satisfies Record<ImportTypes, ParserFunction<any, any, any>>; */
// ^ not supported in our current version of TS, but undoubtedly useful for
// this kind of work.
+18
View File
@@ -51,6 +51,11 @@ export interface OAuth2Info {
REDIRECT_URI: string;
}
export interface CGConfig {
API_KEY: string;
URL: string;
}
export interface TachiServerConfig {
MONGO_DATABASE_NAME: string;
CAPTCHA_SECRET_KEY: string;
@@ -59,6 +64,10 @@ export interface TachiServerConfig {
EAG_API_URL?: string;
MIN_API_URL?: string;
ARC_API_URL?: string;
CG_DEV_CONFIG?: CGConfig;
CG_PROD_CONFIG?: CGConfig;
FLO_OAUTH2_INFO?: OAuth2Info;
EAG_OAUTH2_INFO?: OAuth2Info;
MIN_OAUTH2_INFO?: OAuth2Info;
@@ -136,6 +145,11 @@ const isValidOauth2 = p.optional({
REDIRECT_URI: "string",
});
const isValidCGConfig = p.optional({
API_KEY: "string",
URL: "string",
});
const err = p(config, {
MONGO_DATABASE_NAME: "string",
CAPTCHA_SECRET_KEY: "string",
@@ -144,6 +158,10 @@ const err = p(config, {
EAG_API_URL: p.optional(isValidURL),
MIN_API_URL: p.optional(isValidURL),
ARC_API_URL: p.optional(isValidURL),
CG_DEV_CONFIG: isValidCGConfig,
CG_PROD_CONFIG: isValidCGConfig,
FLO_OAUTH2_INFO: isValidOauth2,
EAG_OAUTH2_INFO: isValidOauth2,
MIN_OAUTH2_INFO: isValidOauth2,
@@ -0,0 +1,138 @@
import { RequireSelfRequestFromUser } from "../../../middleware";
import { Router } from "express";
import db from "external/mongo/db";
import p from "prudence";
import prValidate from "server/middleware/prudence-validate";
import { GetTachiData } from "utils/req-tachi-data";
import type { RequestHandler } from "express-serve-static-core";
import type { CGCardInfo } from "tachi-common";
const router: Router = Router({ mergeParams: true });
const ValidateCGType: RequestHandler = (req, res, next) => {
if (req.params.cgType === "dev" || req.params.cgType === "prod") {
next();
return;
}
return res.status(404).json({
success: false,
description: `No such service 'cg/${req.params.cgType}' is supported.`,
});
};
/**
* Retrieve this user's card info (cardID).
*
* @name GET /api/v1/users/:userID/integrations/cg/:cgType
*/
router.get("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => {
const user = GetTachiData(req, "requestedUser");
const cgType = req.params.cgType as "dev" | "prod";
const cardInfo = await db["cg-card-info"].findOne(
{
userID: user.id,
service: cgType,
},
{
projection: {
// i mean, it's not a security thing really but nobody needs
// to see this.
pin: 0,
},
}
);
if (!cardInfo) {
return res.status(200).json({
success: true,
description: `User has no card info set.`,
body: null,
});
}
return res.status(200).json({
success: true,
description: `Found card info.`,
body: cardInfo,
});
});
/**
* Write new card details for this CG integration.
*
* @name PUT /api/v1/users/:userID/integrations/cg/:cgType
*/
router.put(
"/",
ValidateCGType,
RequireSelfRequestFromUser,
prValidate(
{
cardID: p.regex(/^[a-zA-Z0-9]{16}$/u),
pin: p.regex(/^[0-9]{4}$/u),
},
{
cardID: "Expected 16 characters.",
pin: "Expected 4 digits.",
}
),
async (req, res) => {
const user = GetTachiData(req, "requestedUser");
const cgType = req.params.cgType as "dev" | "prod";
const { cardID, pin } = req.safeBody as {
cardID: string;
pin: string;
};
const newCardInfo: CGCardInfo = {
service: cgType,
cardID,
pin,
userID: user.id,
};
await db["cg-card-info"].update(
{
userID: user.id,
service: cgType,
},
{ $set: newCardInfo },
{
// insert new card info if the user doesn't have it yet.
upsert: true,
}
);
return res.status(200).json({
success: true,
description: `Updated cardID and pin.`,
body: {},
});
}
);
/**
* Unset this user's card details for this CG integration.
*
* @name DELETE /api/v1/users/:userID/integrations/cg/:cgType
*/
router.delete("/", ValidateCGType, RequireSelfRequestFromUser, async (req, res) => {
const user = GetTachiData(req, "requestedUser");
const cgType = req.params.cgType as "dev" | "prod";
await db["cg-card-info"].remove({
userID: user.id,
service: cgType,
});
return res.status(200).json({
success: true,
description: `Deleted stored card info.`,
body: {},
});
});
export default router;
@@ -1,4 +1,5 @@
import arcRouter from "./arc/router";
import cgCgTypeRouter from "./cg/_cgType/router";
import fervidexRouter from "./fervidex/router";
import kaiKaiTypeRouter from "./kai/_kaiType/router";
import ksHookSV6CRouter from "./kshook-sv6c/router";
@@ -7,6 +8,7 @@ import { Router } from "express";
const router: Router = Router({ mergeParams: true });
router.use("/arc", arcRouter);
router.use("/cg/:cgType", cgCgTypeRouter);
router.use("/kai/:kaiType", kaiKaiTypeRouter);
router.use("/fervidex", fervidexRouter);
router.use("/kshook-sv6c", ksHookSV6CRouter);
@@ -0,0 +1 @@
[]