diff --git a/client/src/app/pages/dashboard/import/ImportPage.tsx b/client/src/app/pages/dashboard/import/ImportPage.tsx index 5598bbc87..99946c4e1 100644 --- a/client/src/app/pages/dashboard/import/ImportPage.tsx +++ b/client/src/app/pages/dashboard/import/ImportPage.tsx @@ -133,7 +133,6 @@ function ImportInfoDisplayer({ game }: { game: Game }) { , , , - // , , ); @@ -159,6 +158,8 @@ function ImportInfoDisplayer({ game }: { game: Game }) { />, , , + , + , , ); @@ -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" - /> + />, + , + ); } 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( + , + + ); + } // else if (game === "jubeat") { + // Content.unshift( + // , + // + // ); + // } 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 ( + + ); + case "api/cg-prod-sdvx": + return ( + + ); + case "api/cg-dev-popn": + return ( + + ); + case "api/cg-prod-popn": + return ( + + ); + case "api/cg-dev-museca": + return ( + + ); + case "api/cg-prod-museca": + return ( + ); 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 {importType} + + ); } } diff --git a/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx b/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx index eea35a52e..3b54ab507 100644 --- a/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx +++ b/client/src/app/pages/dashboard/users/FervidexIntegrationPage.tsx @@ -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({ ); } - -function isCardIDValid(cardID: string) { - if (cardID.startsWith("E004")) { - return false; - } - - if (cardID[0] === "C") { - return cardID.length === 13; - } - - return cardID.length === 16; -} diff --git a/client/src/app/routes/ImportRoutes.tsx b/client/src/app/routes/ImportRoutes.tsx index e743eab47..d22b469f8 100644 --- a/client/src/app/routes/ImportRoutes.tsx +++ b/client/src/app/routes/ImportRoutes.tsx @@ -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" /> + + + + + + + + + + + + + + + + + + + diff --git a/client/src/components/imports/CGIntegrationPage.tsx b/client/src/components/imports/CGIntegrationPage.tsx new file mode 100644 index 000000000..b171669ca --- /dev/null +++ b/client/src/components/imports/CGIntegrationPage.tsx @@ -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 ; + } + + const { data, error } = useApiQuery( + `/users/${user.id}/integrations/cg/${cgType}`, + undefined, + [reload] + ); + + if (error) { + return ; + } + + // null is a valid response for this call, so be explicit with going to loading + if (data === undefined) { + return ; + } + + if (data) { + return ; + } else { + return ( + { + 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 & { 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 ( +
+

+ Importing scores from {cgName} card{" "} + {cardID.match(/.{1,4}/gu)?.join(" ")}. +

+ +
+ +
+ +
+ Play on {cgName} a lot? You can synchronise your scores straight from the discord by + typing{" "} + + /sync {cgType === "dev" ? "CG-DEV" : "CG"} {game} + + ! +
+ + +
+ ); +} + +function CGNeedsIntegrate({ + cgType, + onSubmit, +}: Pick & { + onSubmit: (cardID: string, pin: string) => Promise; +}) { + 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 ( +
+

We need to know what card you use on {cgName}!

+ + + + + + This is the card ID that's displayed in game. It should be 16 characters + long. +
+ {cardID.length > 0 && !/^[a-zA-Z0-9]{16}$/u.exec(realCardID) ? ( + + Invalid Card ID. This should be 16 alphanumeric characters. + + ) : ( + cardID.length > 0 && Looking good! + )} +
+
+ + What PIN do you use to card in to {cgName}? +
+ + {pin.length > 0 && !/^[0-9]{4}$/u.exec(pin) ? ( + Invalid PIN. This should be 4 digits. + ) : ( + pin.length > 0 && Looking good! + )} + + +
+ +
+ +
+
+ ); +} diff --git a/client/src/components/util/FormInput.tsx b/client/src/components/util/FormInput.tsx index a02e4b1c8..e6c5d8ab7 100644 --- a/client/src/components/util/FormInput.tsx +++ b/client/src/components/util/FormInput.tsx @@ -8,12 +8,14 @@ export default function FormInput({ setValue, placeholder, as, + type, }: { fieldName: string; value: string; setValue: SetState; placeholder?: string; as?: ElementType; + type?: string; }) { return ( @@ -25,6 +27,7 @@ export default function FormInput({ placeholder={placeholder} value={value} onChange={(e) => setValue(e.target.value)} + type={type} /> ); diff --git a/client/src/util/misc.ts b/client/src/util/misc.ts index 89485d876..6db4f6210 100644 --- a/client/src/util/misc.ts +++ b/client/src/util/misc.ts @@ -582,3 +582,15 @@ export function HumanisedJoinArray(arr: Array, 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; +} diff --git a/common/src/config/static-config.ts b/common/src/config/static-config.ts index 388282cf8..9849e73b9 100644 --- a/common/src/config/static-config.ts +++ b/common/src/config/static-config.ts @@ -37,6 +37,12 @@ export const apiImportTypes: Array = [ "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 = [ diff --git a/common/src/types.ts b/common/src/types.ts index 9f1570826..d499b3394 100644 --- a/common/src/types.ts +++ b/common/src/types.ts @@ -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. */ diff --git a/server/example/conf.json5 b/server/example/conf.json5 index 1424a5318..a7136d0db 100644 --- a/server/example/conf.json5 +++ b/server/example/conf.json5 @@ -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: { diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index 6e3922cb3..150340932 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -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("counters"), "game-stats": monkDB.get("game-stats"), "kai-auth-tokens": monkDB.get("kai-auth-tokens"), + "cg-card-info": monkDB.get("cg-card-info"), + "bms-course-lookup": monkDB.get("bms-course-lookup"), "api-tokens": monkDB.get("api-tokens"), "orphan-scores": monkDB.get("orphan-scores"), diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts index b1ac2c639..ae44a2681 100644 --- a/server/src/external/mongo/schemas.ts +++ b/server/src/external/mongo/schemas.ts @@ -39,6 +39,12 @@ export const DatabaseSchemas: Record = { 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, diff --git a/server/src/lib/score-import/framework/common/score-utils.test.ts b/server/src/lib/score-import/framework/common/score-utils.test.ts index b8ac74349..d89d51bc7 100644 --- a/server/src/lib/score-import/framework/common/score-utils.test.ts +++ b/server/src/lib/score-import/framework/common/score-utils.test.ts @@ -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(); +}); diff --git a/server/src/lib/score-import/framework/common/score-utils.ts b/server/src/lib/score-import/framework/common/score-utils.ts index 97e70154b..03bde4c5f 100644 --- a/server/src/lib/score-import/framework/common/score-utils.ts +++ b/server/src/lib/score-import/framework/common/score-utils.ts @@ -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( } /** - * 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"; +} diff --git a/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts b/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts new file mode 100644 index 000000000..f28451f3d --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/museca/converter.ts @@ -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 = 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}.`); +} diff --git a/server/src/lib/score-import/import-types/common/api-cg/parsers.ts b/server/src/lib/score-import/import-types/common/api-cg/parsers.ts new file mode 100644 index 000000000..10b1cdfaf --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/parsers.ts @@ -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 = { + 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(cgGame: CGSupportedGames, service: CGServices) { + return async ( + userID: integer, + logger: KtLogger + ): Promise> => { + 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, + classHandler: null, + }; + }; +} + +export const ParseCGDevMuseca = CreateCGParser("msc", "dev"); +export const ParseCGDevSDVX = CreateCGParser("sdvx", "dev"); +export const ParseCGDevJubeat = CreateCGParser("jb", "dev"); +export const ParseCGDevPopn = CreateCGParser("popn", "dev"); + +export const ParseCGProdMuseca = CreateCGParser("msc", "prod"); +export const ParseCGProdSDVX = CreateCGParser("sdvx", "prod"); +export const ParseCGProdJubeat = CreateCGParser("jb", "prod"); +export const ParseCGProdPopn = CreateCGParser("popn", "prod"); diff --git a/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts new file mode 100644 index 000000000..f807c75e1 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/popn/converter.ts @@ -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 = 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; +} diff --git a/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts b/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts new file mode 100644 index 000000000..12dfc1b84 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/sdvx/converter.ts @@ -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 = 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.` + ); +} diff --git a/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts b/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts new file mode 100644 index 000000000..149c644ad --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/traverse-api.ts @@ -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> { + const url = GetCGUrl(service, cardInfo, game); + + let validatedRes: CGErrorResponse | CGScoresResponse; + + 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; + } 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()}`; +} diff --git a/server/src/lib/score-import/import-types/common/api-cg/types.ts b/server/src/lib/score-import/import-types/common/api-cg/types.ts new file mode 100644 index 000000000..79f45db31 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/types.ts @@ -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 { + success: true; + data: { + profile: { + id: string; + name: string; + }; + scores: Array; + }; +} + +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; +} diff --git a/server/src/lib/score-import/import-types/common/api-cg/util.ts b/server/src/lib/score-import/import-types/common/api-cg/util.ts new file mode 100644 index 000000000..3e4e123d4 --- /dev/null +++ b/server/src/lib/score-import/import-types/common/api-cg/util.ts @@ -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; + } +} diff --git a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts index 16fc63605..b1895a5f5 100644 --- a/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts +++ b/server/src/lib/score-import/import-types/common/api-kai/traverse-api.ts @@ -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?` ); } diff --git a/server/src/lib/score-import/import-types/common/types.ts b/server/src/lib/score-import/import-types/common/types.ts index 01e441c79..87e15b17e 100644 --- a/server/src/lib/score-import/import-types/common/types.ts +++ b/server/src/lib/score-import/import-types/common/types.ts @@ -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 extends MongoDBDocument { @@ -123,4 +151,4 @@ export interface ParserFunctionReturns { export type ParserFunction> = ( ...args: A -) => ParserFunctionReturns; +) => ParserFunctionReturns | Promise>; diff --git a/server/src/lib/score-import/import-types/converters.ts b/server/src/lib/score-import/import-types/converters.ts index 89990b8fc..51d33b4e1 100644 --- a/server/src/lib/score-import/import-types/converters.ts +++ b/server/src/lib/score-import/import-types/converters.ts @@ -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, }; diff --git a/server/src/lib/score-import/import-types/parsers.ts b/server/src/lib/score-import/import-types/parsers.ts index 2cf1c5e05..ca36af8a5 100644 --- a/server/src/lib/score-import/import-types/parsers.ts +++ b/server/src/lib/score-import/import-types/parsers.ts @@ -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>; */ +// ^ not supported in our current version of TS, but undoubtedly useful for +// this kind of work. diff --git a/server/src/lib/setup/config.ts b/server/src/lib/setup/config.ts index 12f39db72..e5dbedf3e 100644 --- a/server/src/lib/setup/config.ts +++ b/server/src/lib/setup/config.ts @@ -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, diff --git a/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts new file mode 100644 index 000000000..67b6bea64 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/integrations/cg/_cgType/router.ts @@ -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; diff --git a/server/src/server/router/api/v1/users/_userID/integrations/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/router.ts index a407a86b8..92b928730 100644 --- a/server/src/server/router/api/v1/users/_userID/integrations/router.ts +++ b/server/src/server/router/api/v1/users/_userID/integrations/router.ts @@ -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); diff --git a/server/src/test-utils/mock-db/cg-card-info.json b/server/src/test-utils/mock-db/cg-card-info.json new file mode 100644 index 000000000..0637a088a --- /dev/null +++ b/server/src/test-utils/mock-db/cg-card-info.json @@ -0,0 +1 @@ +[] \ No newline at end of file