diff --git a/server/package.json b/server/package.json index 79bbbde78..f51f73cf7 100644 --- a/server/package.json +++ b/server/package.json @@ -78,7 +78,7 @@ "redis": "3.1.2", "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", - "tachi-common": "0.2.38", + "tachi-common": "0.2.42", "ts-node": "10.0.0", "typescript": "4.3.4", "winston": "3.3.3", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 3d0c900ed..76628b9b4 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -60,7 +60,7 @@ specifiers: rimraf: 3.0.2 safe-json-stringify: 1.2.0 supertest: 6.1.3 - tachi-common: 0.2.38 + tachi-common: 0.2.42 tap: 15.0.9 ts-node: 10.0.0 tsconfig-paths: 3.10.1 @@ -102,7 +102,7 @@ dependencies: redis: 3.1.2 rimraf: 3.0.2 safe-json-stringify: 1.2.0 - tachi-common: 0.2.38 + tachi-common: 0.2.42 ts-node: 10.0.0_83f53b0a0c5616d3fa00ed4e30b9ce1b typescript: 4.3.4 winston: 3.3.3 @@ -4161,8 +4161,8 @@ packages: strip-ansi: 6.0.0 dev: true - /tachi-common/0.2.38: - resolution: {integrity: sha512-hsiutwz/TKjYRcV6TsFDhpPHUalKDF2IVJGX5K9UuAAynU2K/wJ5qHG3xWH4pEjYz0B0Vx263HGY2johCEDAAA==} + /tachi-common/0.2.42: + resolution: {integrity: sha512-X+oS08NXgVeiPAFiNUzN7DCmiEJor35NCQkh6ia/kcpTAGkRUwst8NFQnHIca2eXDQmndEJg0KGkpYosMs4KYg==} dependencies: monk: 7.3.4 transitivePeerDependencies: diff --git a/server/scripts/clone-and-anonymise-db.ts b/server/scripts/clone-and-anonymise-db.ts index f4aa265b6..fb2d854f3 100644 --- a/server/scripts/clone-and-anonymise-db.ts +++ b/server/scripts/clone-and-anonymise-db.ts @@ -74,7 +74,7 @@ async function AnonymiseDB(nsTo: string) { logger.info(`Stripped username info.`, { r2 }); for (const collection of [ - "oauth2-clients", + "api-clients", "oauth2-auth-codes", "password-reset-codes", "api-tokens", diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index d69d7d72b..72046d8ac 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -23,7 +23,7 @@ import { InviteCodeDocument, KaiAuthDocument, MilestoneDocument, - OAuth2ApplicationDocument, + TachiAPIClientDocument, OrphanChart, PBScoreDocument, PrivateUserInfoDocument, @@ -170,7 +170,7 @@ const db = { "arc-saved-profiles": monkDB.get("arc-saved-profiles"), "user-settings": monkDB.get("user-settings"), "user-private-information": monkDB.get("user-private-information"), - "oauth2-clients": monkDB.get("oauth2-clients"), + "api-clients": monkDB.get("api-clients"), "oauth2-auth-codes": // i've inlined this one because i don't see it appearing anywhere else. monkDB.get<{ code: string; userID: integer; createdOn: number }>("oauth2-auth-codes"), @@ -212,7 +212,7 @@ export type StaticDatabases = | "game-stats-snapshots" | "arc-saved-profiles" | "user-private-information" - | "oauth2-clients" + | "api-clients" | "oauth2-auth-codes" | "fer-settings" | "orphan-chart-queue" diff --git a/server/src/external/mongo/indexes.ts b/server/src/external/mongo/indexes.ts index 1732e1d89..475b0678a 100644 --- a/server/src/external/mongo/indexes.ts +++ b/server/src/external/mongo/indexes.ts @@ -4,7 +4,7 @@ import { IndexOptions } from "mongodb"; import CreateLogCtx from "lib/logger/logger"; import { TachiConfig } from "lib/setup/config"; import { ONE_DAY } from "lib/constants/time"; -import { Databases } from "./db"; +import db, { Databases, monkDB } from "./db"; import { Random20Hex } from "utils/misc"; const logger = CreateLogCtx(__filename); @@ -84,6 +84,7 @@ const staticIndexes: Partial> = { "fer-settings": [index({ userID: 1 }, UNIQUE)], counters: [index({ counterName: 1 }, UNIQUE)], "class-achievements": [index({ game: 1, playtype: 1, timeAchieved: 1 })], + "api-clients": [index({ clientID: 1 }, UNIQUE)], }; const indexes: Partial> = staticIndexes; @@ -157,3 +158,26 @@ export async function SetIndexes(mongoUrl: string, reset: boolean) { await monkDb.close(); } + +export function SetIndexesIfNoneSet() { + // If no indexes are set, then we need to load mongo indexes. + return db.users + .indexes() + .then((r) => { + // If there's only one index on users + // that means that only _id has indexes. + // This means that there are likely to be no indexes + // configured in the database. + if (Object.keys(r).length === 1) { + logger.info(`First-time Mongo startup detected. Running SetIndexes.`); + SetIndexesWithDB(monkDB, true); + } + }) + .catch((err) => { + logger.info( + `Error in finding users collection. First time startup likely. Running SetIndexes.`, + err + ); + SetIndexesWithDB(monkDB, true); + }); +} diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts index 305b0ccec..031618551 100644 --- a/server/src/external/mongo/schemas.ts +++ b/server/src/external/mongo/schemas.ts @@ -429,14 +429,26 @@ export const DatabaseSchemas: Record = { userID: p.isPositiveNonZeroInteger, createdOn: p.isPositive, }), - "oauth2-clients": prSchemaify({ + "api-clients": prSchemaify({ clientID: "string", clientSecret: "string", name: "string", author: p.isPositiveNonZeroInteger, requestedPermissions: [p.isIn(Object.keys(AllPermissions))], - redirectUri: "string", + redirectUri: "?string", webhookUri: "?string", + apiKeyTemplate: p.nullable((self) => { + if (typeof self !== "string") { + return "Expected a string."; + } + + if (!self.includes("%%TACHI_KEY%%")) { + return "Template must include %%TACHI_KEY%%."; + } + + return true; + }), + apiKeyFilename: "?string", }), "orphan-chart-queue": prSchemaify({ idString: p.isIn(allIDStrings), diff --git a/server/src/lib/builtin-clients/builtin-clients.ts b/server/src/lib/builtin-clients/builtin-clients.ts new file mode 100644 index 000000000..a41e294b9 --- /dev/null +++ b/server/src/lib/builtin-clients/builtin-clients.ts @@ -0,0 +1,155 @@ +/* eslint-disable no-await-in-loop */ +import db from "external/mongo/db"; +import { ServerConfig, TachiConfig } from "lib/setup/config"; +import { TachiAPIClientDocument, UserAuthLevels } from "tachi-common"; +import { Random20Hex } from "utils/misc"; +import fjsh from "fast-json-stable-hash"; +import CreateLogCtx from "lib/logger/logger"; +import { DatabaseSchemas } from "external/mongo/schemas"; +import { FormatPrError } from "utils/prudence"; +import p from "prudence"; + +const logger = CreateLogCtx(__filename); + +type DefaultClients = Omit[]; + +// Defines some Tachi API Clients that should come default with a Tachi +// environment. +// These use the special Client ID prefix "CX" instead of "CI", which +// means they cannot possibly be collided.1 +const KtchiDefaultClients: DefaultClients = [ + { + name: "Fervidex", + webhookUri: null, + redirectUri: null, + requestedPermissions: ["submit_score"], + clientID: "CXFervidex", + apiKeyFilename: "kamaitachi.fervidex.json", + apiKeyTemplate: JSON.stringify( + { + url: `${ServerConfig.OUR_URL}/ir/fervidex`, + token: "%%TACHI_KEY%%", + }, + null, + "\t" + ), + }, + { + name: "Barbatos", + webhookUri: null, + redirectUri: null, + requestedPermissions: ["submit_score"], + clientID: "CXBarbatos", + apiKeyFilename: "barbatos.json", + apiKeyTemplate: JSON.stringify( + { + api_key: "%%TACHI_KEY%%", + }, + null, + "\t" + ), + }, + { + name: "Konaste Hook", + webhookUri: null, + redirectUri: null, + requestedPermissions: ["submit_score"], + clientID: "CXKsHook", + apiKeyFilename: "kamaitachi.kshook.json", + apiKeyTemplate: JSON.stringify( + { + url: `${ServerConfig.OUR_URL}/ir/kshook`, + token: "%%TACHI_KEY%%", + games: ["sv3c"], + }, + null, + "\t" + ), + }, +]; + +const BtchiDefaultClients: DefaultClients = [ + { + name: "Beatoraja IR", + webhookUri: null, + redirectUri: null, + requestedPermissions: ["submit_score"], + clientID: "CXBeatorajaIR", + apiKeyTemplate: null, + apiKeyFilename: null, + }, + { + name: "USC IR", + webhookUri: null, + redirectUri: null, + requestedPermissions: ["submit_score"], + clientID: "CXUSCIR", + apiKeyTemplate: null, + apiKeyFilename: null, + }, +]; + +export async function LoadDefaultClients() { + if (TachiConfig.TYPE === "ktchi") { + await LoadClients(KtchiDefaultClients); + } else if (TachiConfig.TYPE === "btchi") { + await LoadClients(BtchiDefaultClients); + } else { + await LoadClients(KtchiDefaultClients); + await LoadClients(BtchiDefaultClients); + } +} + +async function LoadClients(clients: DefaultClients) { + const firstAdmin = await db.users.findOne({ + authLevel: UserAuthLevels.ADMIN, + }); + + if (!firstAdmin) { + logger.error( + `There are no admins on this instance of tachi-server. We cannot create default API Clients!` + ); + return; + } + + for (const client of clients) { + const exists = await db["api-clients"].findOne( + { + clientID: client.clientID, + }, + { + projection: { + clientSecret: 0, + author: 0, + }, + } + ); + + // Skip if nothing has changed. + if (fjsh.hash(exists, "sha256") === fjsh.hash(client, "sha256")) { + continue; + } + + const realClient: TachiAPIClientDocument = { + ...client, + clientSecret: `CS${Random20Hex()}`, + author: 1, + }; + + try { + DatabaseSchemas["api-clients"](realClient); + } catch (err) { + logger.error(`Invalid API Client ${client.name}: ${FormatPrError(err)}.`); + continue; + } + + // No replaceOne support in monk -- have to do this. + await db["api-clients"].remove({ + clientID: client.clientID, + }); + + await db["api-clients"].insert(realClient); + + logger.info(`Loaded/Modified new built-in client ${client.name}.`); + } +} diff --git a/server/src/lib/score-import/framework/goals/goals.ts b/server/src/lib/score-import/framework/goals/goals.ts index 08f2c2d14..3ba453b25 100644 --- a/server/src/lib/score-import/framework/goals/goals.ts +++ b/server/src/lib/score-import/framework/goals/goals.ts @@ -118,6 +118,8 @@ export async function ProcessGoal( goalID: goal.goalID, old: oldData, new: newData, + game: goal.game, + playtype: goal.playtype, }, }); } diff --git a/server/src/lib/score-import/framework/milestones/milestones.ts b/server/src/lib/score-import/framework/milestones/milestones.ts index ada8b96e6..8b98a1178 100644 --- a/server/src/lib/score-import/framework/milestones/milestones.ts +++ b/server/src/lib/score-import/framework/milestones/milestones.ts @@ -136,6 +136,8 @@ export async function UpdateUsersMilestones( content: { userID, ...milestoneInfo, + game, + playtype: milestone.playtype, }, }); } diff --git a/server/src/lib/webhooks/webhooks.ts b/server/src/lib/webhooks/webhooks.ts index 7ae364dc4..d615fa65c 100644 --- a/server/src/lib/webhooks/webhooks.ts +++ b/server/src/lib/webhooks/webhooks.ts @@ -6,7 +6,7 @@ const logger = CreateLogCtx(__filename); // @todo make use of aggressive caching here? export async function GetWebhookUrlInfo() { - const urls = await db["oauth2-clients"].find( + const urls = await db["api-clients"].find( { webhookUri: { $ne: null } }, { projection: { webhookUri: 1, clientSecret: 1 } } ); diff --git a/server/src/main.ts b/server/src/main.ts index df9c4faa1..2ba4ec9a9 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -1,11 +1,12 @@ import { spawn } from "child_process"; -import db, { monkDB } from "external/mongo/db"; -import { SetIndexesWithDB } from "external/mongo/indexes"; +import db from "external/mongo/db"; +import { SetIndexesIfNoneSet } from "external/mongo/indexes"; import { InitSequenceDocs } from "external/mongo/sequence-docs"; import fs from "fs"; import https from "https"; +import { LoadDefaultClients } from "lib/builtin-clients/builtin-clients"; import CreateLogCtx from "lib/logger/logger"; -import { Environment, ServerConfig, TachiConfig, TachiServerConfig } from "lib/setup/config"; +import { Environment, ServerConfig, TachiConfig } from "lib/setup/config"; import path from "path"; import server from "server/server"; import { InitaliseFolderChartLookup } from "utils/folder"; @@ -17,35 +18,22 @@ logger.info(`Booting ${TachiConfig.NAME} - ${FormatVersion()} [ENV: ${Environmen logger.info(`Log level is set to ${ServerConfig.LOG_LEVEL}.`); logger.info(`Loading sequence documents...`); -InitSequenceDocs(); -// If no indexes are set, then we need to load mongo indexes. -db.users - .indexes() - .then((r) => { - // If there's only one index on users - // that means that only _id has indexes. - // This means that there are likely to be no indexes - // configured in the database. - if (Object.keys(r).length === 1) { - logger.info(`First-time Mongo startup detected. Running SetIndexes.`); - SetIndexesWithDB(monkDB, true); +async function RunOnInit() { + await InitSequenceDocs(); + await SetIndexesIfNoneSet(); + + await db["folder-chart-lookup"].findOne().then((r) => { + // If there are no folder chart lookups, initialise them. + if (!r) { + InitaliseFolderChartLookup(); } - }) - .catch((err) => { - logger.info( - `Error in finding users collection. First time startup likely. Running SetIndexes.`, - err - ); - SetIndexesWithDB(monkDB, true); }); -db["folder-chart-lookup"].findOne().then((r) => { - // If there are no folder chart lookups, initialise them. - if (!r) { - InitaliseFolderChartLookup(); - } -}); + await LoadDefaultClients(); +} + +RunOnInit(); if (ServerConfig.ENABLE_SERVER_HTTPS) { logger.warn( diff --git a/server/src/server/router/api/v1/oauth/clients/middleware.test.ts b/server/src/server/router/api/v1/clients/middleware.test.ts similarity index 96% rename from server/src/server/router/api/v1/oauth/clients/middleware.test.ts rename to server/src/server/router/api/v1/clients/middleware.test.ts index 9d545a017..db836dd1c 100644 --- a/server/src/server/router/api/v1/oauth/clients/middleware.test.ts +++ b/server/src/server/router/api/v1/clients/middleware.test.ts @@ -15,7 +15,7 @@ t.test("#GetClientFromID", (t) => { }); t.strictSame( - req[SYMBOL_TachiData]?.oauth2ClientDoc, + req[SYMBOL_TachiData]?.apiClientDoc, { clientID: "OAUTH2_CLIENT_ID", // clientSecret: "OAUTH2_CLIENT_SECRET", @@ -24,6 +24,8 @@ t.test("#GetClientFromID", (t) => { requestedPermissions: ["customise_profile"], redirectUri: "https://example.com/callback", webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }, "Should assign clientDoc with secret ommitted." ); diff --git a/server/src/server/router/api/v1/oauth/clients/middleware.ts b/server/src/server/router/api/v1/clients/middleware.ts similarity index 84% rename from server/src/server/router/api/v1/oauth/clients/middleware.ts rename to server/src/server/router/api/v1/clients/middleware.ts index 502ec1533..3c8d6b02d 100644 --- a/server/src/server/router/api/v1/oauth/clients/middleware.ts +++ b/server/src/server/router/api/v1/clients/middleware.ts @@ -2,11 +2,11 @@ import { RequestHandler } from "express"; import db from "external/mongo/db"; import { SYMBOL_TachiData } from "lib/constants/tachi"; import { Environment } from "lib/setup/config"; -import { OAuth2ApplicationDocument } from "tachi-common"; +import { TachiAPIClientDocument } from "tachi-common"; import { AssignToReqTachiData } from "utils/req-tachi-data"; export const GetClientFromID: RequestHandler = async (req, res, next) => { - const client = await db["oauth2-clients"].findOne( + const client = await db["api-clients"].findOne( { clientID: req.params.clientID, }, @@ -24,13 +24,13 @@ export const GetClientFromID: RequestHandler = async (req, res, next) => { }); } - AssignToReqTachiData(req, { oauth2ClientDoc: client }); + AssignToReqTachiData(req, { apiClientDoc: client }); return next(); }; export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => { - let client: Omit; + let client: Omit; // @hack // Sadly, expMiddlewareMock doesn't support mounting symbol props on @@ -42,7 +42,7 @@ export const RequireOwnershipOfClient: RequestHandler = (req, res, next) => { // in testing. client = req.body.__terribleHackOauth2ClientDoc; } else { - client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + client = req[SYMBOL_TachiData]!.apiClientDoc!; } const user = req.session.tachi?.user; diff --git a/server/src/server/router/api/v1/oauth/clients/router.test.ts b/server/src/server/router/api/v1/clients/router.test.ts similarity index 68% rename from server/src/server/router/api/v1/oauth/clients/router.test.ts rename to server/src/server/router/api/v1/clients/router.test.ts index bca1cc705..9d98e0748 100644 --- a/server/src/server/router/api/v1/oauth/clients/router.test.ts +++ b/server/src/server/router/api/v1/clients/router.test.ts @@ -1,12 +1,13 @@ import db from "external/mongo/db"; +import { UserAuthLevels, APITokenDocument, TachiAPIClientDocument } from "tachi-common"; import { ServerConfig } from "lib/setup/config"; -import { APITokenDocument, OAuth2ApplicationDocument } from "tachi-common"; + import t from "tap"; import { CreateFakeAuthCookie } from "test-utils/fake-auth"; import mockApi from "test-utils/mock-api"; import ResetDBState from "test-utils/resets"; -const clientDataset: OAuth2ApplicationDocument[] = [ +const clientDataset: TachiAPIClientDocument[] = [ { author: 1, clientID: "CLIENT_1", @@ -15,6 +16,8 @@ const clientDataset: OAuth2ApplicationDocument[] = [ redirectUri: "example.com", requestedPermissions: ["customise_profile"], webhookUri: null, + apiKeyFilename: null, + apiKeyTemplate: null, }, { author: 1, @@ -24,6 +27,8 @@ const clientDataset: OAuth2ApplicationDocument[] = [ redirectUri: "example.com", requestedPermissions: ["customise_profile"], webhookUri: null, + apiKeyFilename: null, + apiKeyTemplate: null, }, { author: 2, @@ -33,20 +38,22 @@ const clientDataset: OAuth2ApplicationDocument[] = [ redirectUri: "example.com", requestedPermissions: ["customise_profile"], webhookUri: null, + apiKeyFilename: null, + apiKeyTemplate: null, }, ]; -t.test("GET /api/v1/oauth/clients", async (t) => { +t.test("GET /api/v1/clients", async (t) => { t.beforeEach(ResetDBState); t.beforeEach(async () => { - await db["oauth2-clients"].remove({}); - await db["oauth2-clients"].insert(clientDataset); + await db["api-clients"].remove({}); + await db["api-clients"].insert(clientDataset); }); const cookie = await CreateFakeAuthCookie(mockApi); t.test("Should retrieve your clients.", async (t) => { - const res = await mockApi.get("/api/v1/oauth/clients").set("Cookie", cookie); + const res = await mockApi.get("/api/v1/clients").set("Cookie", cookie); t.equal(res.statusCode, 200); @@ -63,6 +70,8 @@ t.test("GET /api/v1/oauth/clients", async (t) => { redirectUri: "example.com", requestedPermissions: ["customise_profile"], webhookUri: null, + apiKeyFilename: null, + apiKeyTemplate: null, }, { author: 1, @@ -72,6 +81,8 @@ t.test("GET /api/v1/oauth/clients", async (t) => { redirectUri: "example.com", requestedPermissions: ["customise_profile"], webhookUri: null, + apiKeyFilename: null, + apiKeyTemplate: null, }, ] ); @@ -80,12 +91,12 @@ t.test("GET /api/v1/oauth/clients", async (t) => { }); t.test("Requires self-key level authentication.", async (t) => { - const res = await mockApi.get("/api/v1/oauth/clients"); + const res = await mockApi.get("/api/v1/clients"); t.equal(res.statusCode, 401); const res2 = await mockApi - .get("/api/v1/oauth/clients") + .get("/api/v1/clients") .set("Authorization", "Bearer fake_api_token"); t.equal(res2.statusCode, 401); @@ -96,24 +107,27 @@ t.test("GET /api/v1/oauth/clients", async (t) => { t.end(); }); -t.test("POST /api/v1/oauth/clients/create", async (t) => { +t.test("POST /api/v1/clients/create", async (t) => { t.beforeEach(ResetDBState); const cookie = await CreateFakeAuthCookie(mockApi); t.test("Should create a new client.", async (t) => { const res = await mockApi - .post("/api/v1/oauth/clients/create") + .post("/api/v1/clients/create") .send({ name: "Hello World", redirectUri: "https://example.com/callback", permissions: ["customise_profile"], + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }) .set("Cookie", cookie); t.equal(res.statusCode, 200); - const dbRes = await db["oauth2-clients"].findOne({ clientID: res.body.body.clientID }); + const dbRes = await db["api-clients"].findOne({ clientID: res.body.body.clientID }); t.not(dbRes, null, "Should be saved in the database."); @@ -124,22 +138,28 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => { t.test("Should validate names to be between 3 and 80 characters.", async (t) => { const res = await mockApi - .post("/api/v1/oauth/clients/create") + .post("/api/v1/clients/create") .send({ name: "2", redirectUri: "https://example.com/callback", permissions: ["customise_profile"], + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }) .set("Cookie", cookie); t.equal(res.statusCode, 400); const res2 = await mockApi - .post("/api/v1/oauth/clients/create") + .post("/api/v1/clients/create") .send({ name: "2".repeat(100), redirectUri: "https://example.com/callback", permissions: ["customise_profile"], + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }) .set("Cookie", cookie); @@ -150,11 +170,14 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => { t.test("Should validate urls to be between 3 and 80 characters.", async (t) => { const res = await mockApi - .post("/api/v1/oauth/clients/create") + .post("/api/v1/clients/create") .send({ name: "Hello World", redirectUri: "ftp://example.com/callback", permissions: ["customise_profile"], + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }) .set("Cookie", cookie); @@ -165,11 +188,14 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => { t.test("Should validate permissions.", async (t) => { const res = await mockApi - .post("/api/v1/oauth/clients/create") + .post("/api/v1/clients/create") .send({ name: "Hello World", redirectUri: "http://example.com/callback", permissions: ["permission_that_doesnt_exist"], + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }) .set("Cookie", cookie); @@ -178,31 +204,38 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => { t.end(); }); - t.test("Should cap a user at OAUTH_CLIENT_CAP.", async (t) => { + // Currently skipped as its difficult to mock user auth level. + t.skip("Should cap a user at OAUTH_CLIENT_CAP.", async (t) => { for (let i = 0; i < ServerConfig.OAUTH_CLIENT_CAP; i++) { // eslint-disable-next-line no-await-in-loop await mockApi - .post("/api/v1/oauth/clients/create") + .post("/api/v1/clients/create") .send({ name: "Hello World", redirectUri: "https://example.com/callback", permissions: ["customise_profile"], + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }) .set("Cookie", cookie); } const res = await mockApi - .post("/api/v1/oauth/clients/create") + .post("/api/v1/clients/create") .send({ name: "Hello World", redirectUri: "https://example.com/callback", permissions: ["customise_profile"], + webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }) .set("Cookie", cookie); t.equal(res.statusCode, 400); - const dbCount = await db["oauth2-clients"].count({ author: 1 }); + const dbCount = await db["api-clients"].count({ author: 1 }); t.equal(dbCount, ServerConfig.OAUTH_CLIENT_CAP); @@ -212,11 +245,11 @@ t.test("POST /api/v1/oauth/clients/create", async (t) => { t.end(); }); -t.test("GET /api/v1/oauth/clients/:clientID", (t) => { +t.test("GET /api/v1/clients/:clientID", (t) => { t.beforeEach(ResetDBState); t.test("Should return information about the client at that ID.", async (t) => { - const res = await mockApi.get("/api/v1/oauth/clients/OAUTH2_CLIENT_ID"); + const res = await mockApi.get("/api/v1/clients/OAUTH2_CLIENT_ID"); t.strictSame(res.body.body, { clientID: "OAUTH2_CLIENT_ID", @@ -226,13 +259,15 @@ t.test("GET /api/v1/oauth/clients/:clientID", (t) => { requestedPermissions: ["customise_profile"], redirectUri: "https://example.com/callback", webhookUri: null, + apiKeyTemplate: null, + apiKeyFilename: null, }); t.end(); }); t.test("Should return 404 if client doesn't exist.", async (t) => { - const res = await mockApi.get("/api/v1/oauth/clients/BAD_CLIENT"); + const res = await mockApi.get("/api/v1/clients/BAD_CLIENT"); t.equal(res.statusCode, 404); @@ -242,18 +277,18 @@ t.test("GET /api/v1/oauth/clients/:clientID", (t) => { t.end(); }); -t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { +t.test("PATCH /api/v1/clients/:clientID", async (t) => { t.beforeEach(ResetDBState); t.beforeEach(async () => { - await db["oauth2-clients"].remove({}); - await db["oauth2-clients"].insert(clientDataset); + await db["api-clients"].remove({}); + await db["api-clients"].insert(clientDataset); }); const cookie = await CreateFakeAuthCookie(mockApi); t.test("Should be able to modify a clients name.", async (t) => { const res = await mockApi - .patch("/api/v1/oauth/clients/CLIENT_1") + .patch("/api/v1/clients/CLIENT_1") .send({ name: "NEW NAME" }) .set("Cookie", cookie); @@ -261,7 +296,7 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { t.equal(res.body.body.name, "NEW NAME"); - const dbRes = await db["oauth2-clients"].findOne({ + const dbRes = await db["api-clients"].findOne({ clientID: "CLIENT_1", }); @@ -272,7 +307,7 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { t.test("Should be able to modify a clients webhookUri.", async (t) => { const res = await mockApi - .patch("/api/v1/oauth/clients/CLIENT_1") + .patch("/api/v1/clients/CLIENT_1") .send({ webhookUri: "https://example.com" }) .set("Cookie", cookie); @@ -280,7 +315,7 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { t.equal(res.body.body.webhookUri, "https://example.com"); - const dbRes = await db["oauth2-clients"].findOne({ + const dbRes = await db["api-clients"].findOne({ clientID: "CLIENT_1", }); @@ -291,14 +326,14 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { t.test("Must validate name to be between 3 and 80 characters.", async (t) => { const res = await mockApi - .patch("/api/v1/oauth/clients/CLIENT_1") + .patch("/api/v1/clients/CLIENT_1") .send({ name: "2" }) .set("Cookie", cookie); t.equal(res.statusCode, 400); const res2 = await mockApi - .patch("/api/v1/oauth/clients/CLIENT_1") + .patch("/api/v1/clients/CLIENT_1") .send({ name: "2".repeat(100) }) .set("Cookie", cookie); @@ -308,10 +343,7 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { }); t.test("Must provide name to modify.", async (t) => { - const res = await mockApi - .patch("/api/v1/oauth/clients/CLIENT_1") - .send({}) - .set("Cookie", cookie); + const res = await mockApi.patch("/api/v1/clients/CLIENT_1").send({}).set("Cookie", cookie); t.equal(res.statusCode, 400); @@ -320,13 +352,13 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { t.test("Must be owner of client.", async (t) => { const res = await mockApi - .patch("/api/v1/oauth/clients/CLIENT_3") + .patch("/api/v1/clients/CLIENT_3") .send({ name: "foo" }) .set("Cookie", cookie); t.equal(res.statusCode, 403); - const res2 = await mockApi.patch("/api/v1/oauth/clients/CLIENT_3").send({ name: "foo" }); + const res2 = await mockApi.patch("/api/v1/clients/CLIENT_3").send({ name: "foo" }); t.equal(res2.statusCode, 401); @@ -336,25 +368,25 @@ t.test("PATCH /api/v1/oauth/clients/:clientID", async (t) => { t.end(); }); -t.test("POST /api/v1/oauth/clients/:clientID/reset-secret", async (t) => { +t.test("POST /api/v1/clients/:clientID/reset-secret", async (t) => { t.beforeEach(ResetDBState); t.beforeEach(async () => { - await db["oauth2-clients"].remove({}); - await db["oauth2-clients"].insert(clientDataset); + await db["api-clients"].remove({}); + await db["api-clients"].insert(clientDataset); }); const cookie = await CreateFakeAuthCookie(mockApi); t.test("Should reset the client's secret.", async (t) => { const res = await mockApi - .post("/api/v1/oauth/clients/CLIENT_1/reset-secret") + .post("/api/v1/clients/CLIENT_1/reset-secret") .set("Cookie", cookie); t.equal(res.statusCode, 200); t.not(res.body.body.clientSecret, "SECRET_1", "Should return the new secret."); - const dbRes = await db["oauth2-clients"].findOne({ + const dbRes = await db["api-clients"].findOne({ clientID: "CLIENT_1", }); @@ -365,12 +397,12 @@ t.test("POST /api/v1/oauth/clients/:clientID/reset-secret", async (t) => { t.test("Must be owner of client.", async (t) => { const res = await mockApi - .post("/api/v1/oauth/clients/CLIENT_3/reset-secret") + .post("/api/v1/clients/CLIENT_3/reset-secret") .set("Cookie", cookie); t.equal(res.statusCode, 403); - const res2 = await mockApi.post("/api/v1/oauth/clients/CLIENT_3/reset-secret"); + const res2 = await mockApi.post("/api/v1/clients/CLIENT_3/reset-secret"); t.equal(res2.statusCode, 401); @@ -380,11 +412,11 @@ t.test("POST /api/v1/oauth/clients/:clientID/reset-secret", async (t) => { t.end(); }); -t.test("DELETE /api/v1/oauth/clients/:clientID", async (t) => { +t.test("DELETE /api/v1/clients/:clientID", async (t) => { t.beforeEach(ResetDBState); t.beforeEach(async () => { - await db["oauth2-clients"].remove({}); - await db["oauth2-clients"].insert(clientDataset); + await db["api-clients"].remove({}); + await db["api-clients"].insert(clientDataset); }); const cookie = await CreateFakeAuthCookie(mockApi); @@ -392,22 +424,22 @@ t.test("DELETE /api/v1/oauth/clients/:clientID", async (t) => { t.test("Should destroy the client and all associated api keys.", async (t) => { await db["api-tokens"].insert([ { - fromOAuth2Client: "CLIENT_1", + fromAPIClient: "CLIENT_1", token: "foo", userID: 1, }, { - fromOAuth2Client: "CLIENT_1", + fromAPIClient: "CLIENT_1", token: "bar", userID: 1, }, ] as APITokenDocument[]); - const res = await mockApi.delete("/api/v1/oauth/clients/CLIENT_1").set("Cookie", cookie); + const res = await mockApi.delete("/api/v1/clients/CLIENT_1").set("Cookie", cookie); t.equal(res.statusCode, 200); - const dbRes = await db["oauth2-clients"].findOne({ clientID: "CLIENT_1" }); + const dbRes = await db["api-clients"].findOne({ clientID: "CLIENT_1" }); t.equal(dbRes, null, "Should no longer exist."); @@ -419,11 +451,11 @@ t.test("DELETE /api/v1/oauth/clients/:clientID", async (t) => { }); t.test("Must be owner of client.", async (t) => { - const res = await mockApi.delete("/api/v1/oauth/clients/CLIENT_3").set("Cookie", cookie); + const res = await mockApi.delete("/api/v1/clients/CLIENT_3").set("Cookie", cookie); t.equal(res.statusCode, 403); - const res2 = await mockApi.delete("/api/v1/oauth/clients/CLIENT_3"); + const res2 = await mockApi.delete("/api/v1/clients/CLIENT_3"); t.equal(res2.statusCode, 401); diff --git a/server/src/server/router/api/v1/oauth/clients/router.ts b/server/src/server/router/api/v1/clients/router.ts similarity index 60% rename from server/src/server/router/api/v1/oauth/clients/router.ts rename to server/src/server/router/api/v1/clients/router.ts index 5689aaded..f7226d4b7 100644 --- a/server/src/server/router/api/v1/oauth/clients/router.ts +++ b/server/src/server/router/api/v1/clients/router.ts @@ -6,7 +6,7 @@ import db from "external/mongo/db"; import { GetClientFromID, RequireOwnershipOfClient } from "./middleware"; import { DedupeArr, DeleteUndefinedProps, IsValidURL, Random20Hex } from "utils/misc"; import CreateLogCtx from "lib/logger/logger"; -import { APIPermissions } from "tachi-common"; +import { APIPermissions, TachiAPIClientDocument, UserAuthLevels } from "tachi-common"; import { AllPermissions } from "server/middleware/auth"; import { ServerConfig } from "lib/setup/config"; import { FormatUserDoc } from "utils/user"; @@ -21,7 +21,7 @@ const router: Router = Router({ mergeParams: true }); * * @warn This also returns the client_secrets! Those *have* to be kept secret. * - * @name GET /api/v1/oauth/clients + * @name GET /api/v1/clients */ router.get("/", async (req, res) => { const user = req.session.tachi?.user; @@ -33,7 +33,7 @@ router.get("/", async (req, res) => { }); } - const clients = await db["oauth2-clients"].find({ + const clients = await db["api-clients"].find({ author: user.id, }); @@ -45,19 +45,40 @@ router.get("/", async (req, res) => { }); /** - * Create a new OAuth2 Client. Requires session-level auth. + * Create a new API Client. Requires session-level auth. * * @param name - A string that identifies this client. * @param redirectUri - The redirectUri this client uses. + * @param webhookUri - Optionally, a webhookUri to call with webhook events. + * @param apiKeyTemplate - Optionally, a static format to apply when doing static auth. + * @param apiKeyFilename - Optionally, a filename to automatically download the template to, when doing + * static flow. * @param permissions - An array of APIPermissions this client is expected to use. * - * @name POST /api/v1/oauth/clients/create + * @name POST /api/v1/clients/create */ router.post( "/create", prValidate({ name: p.isBoundedString(3, 80), - redirectUri: "string", + redirectUri: "?string", + webhookUri: "?string", + apiKeyTemplate: (self) => { + if (self === null) { + return true; + } + + if (typeof self !== "string") { + return "Expected a string."; + } + + if (!self.includes("%%TACHI_KEY%%")) { + return "Must contain %%TACHI_KEY%% as part of the template."; + } + + return true; + }, + apiKeyFilename: "?string", permissions: [p.isIn(Object.keys(AllPermissions))], }), async (req, res) => { @@ -68,50 +89,70 @@ router.post( }); } - const existingClients = await db["oauth2-clients"].find({ + const existingClients = await db["api-clients"].find({ author: req.session.tachi.user.id, }); - if (existingClients.length >= ServerConfig.OAUTH_CLIENT_CAP) { + // Note: Admins are excluded from the API client cap. + if ( + req.session.tachi.user.authLevel !== UserAuthLevels.ADMIN && + existingClients.length >= ServerConfig.OAUTH_CLIENT_CAP + ) { return res.status(400).json({ success: false, - description: `You have created too many OAuth2 clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`, + description: `You have created too many API clients. The current cap is ${ServerConfig.OAUTH_CLIENT_CAP}.`, }); } const permissions = DedupeArr(req.body.permissions); - if (!IsValidURL(req.body.redirectUri)) { + if (permissions.length === 0) { return res.status(400).json({ success: false, - description: `Invalid URL for ${req.body.redirectUri}.`, + description: `Invalid permissions -- Need to require atleast one.`, + }); + } + + if (req.body.redirectUri !== null && !IsValidURL(req.body.redirectUri)) { + return res.status(400).json({ + success: false, + description: `Invalid Redirect URL.`, + }); + } + + if (req.body.webhookUri !== null && !IsValidURL(req.body.webhookUri)) { + return res.status(400).json({ + success: false, + description: `Invalid Webhook URL.`, }); } const clientID = `CI${Random20Hex()}`; const clientSecret = `CS${Random20Hex()}`; - const clientDoc = { + const clientDoc: TachiAPIClientDocument = { clientID, clientSecret, requestedPermissions: permissions, name: req.body.name, author: req.session.tachi.user.id, redirectUri: req.body.redirectUri, - webhookUri: null, + webhookUri: req.body.webhookUri ?? null, + apiKeyFilename: req.body.apiKeyFilename ?? null, + apiKeyTemplate: req.body.apiKeyTemplate ?? null, }; - await db["oauth2-clients"].insert(clientDoc); + await db["api-clients"].insert(clientDoc); logger.info( - `User ${FormatUserDoc(req.session.tachi.user)} created a new OAuth2 Client ${ + `User ${FormatUserDoc(req.session.tachi.user)} created a new API Client ${ req.body.name } (${clientID}).` ); return res.status(200).json({ success: true, - description: `Created a new OAuth2 client.`, + description: `Created a new API client.`, body: clientDoc, }); } @@ -120,10 +161,10 @@ router.post( /** * Retrieves information about the client at this ID. * - * @name GET /api/v1/oauth/clients/:clientID + * @name GET /api/v1/clients/:clientID */ router.get("/:clientID", GetClientFromID, (req, res) => { - const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + const client = req[SYMBOL_TachiData]!.apiClientDoc!; return res.status(200).json({ success: true, @@ -138,8 +179,11 @@ router.get("/:clientID", GetClientFromID, (req, res) => { * * @param name - Change the name of this client. * @param webhookUri - Change a bound webhookUri for this client. + * @param redirectUri - Change a bound redirectUri for this client. + * @param apiKeyFormat - Change the APIKeyFormat for this client. + * @param apiKeyFilename - Change the APIKeyFilename for this client. * - * @name PATCH /api/v1/oauth/clients/:clientID + * @name PATCH /api/v1/clients/:clientID */ router.patch( "/:clientID", @@ -147,6 +191,18 @@ router.patch( RequireOwnershipOfClient, prValidate({ name: p.optional(p.isBoundedString(3, 80)), + apiKeyFormat: optNull((self) => { + if (typeof self !== "string") { + return "Expected a string."; + } + + if (!self.includes("%%TACHI_KEY%%")) { + return "Must contain a %%TACHI_KEY%% placeholder."; + } + + return true; + }), + apiKeyFilename: p.optional(p.isBoundedString(3, 80)), webhookUri: optNull((self) => { if (typeof self !== "string") { return "Expected a string."; @@ -159,7 +215,7 @@ router.patch( return true; }), - redirectUri: optNull((self) => { + redirectUri: p.optional((self) => { if (typeof self !== "string") { return "Expected a string."; } @@ -173,7 +229,7 @@ router.patch( }), }), async (req, res) => { - const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + const client = req[SYMBOL_TachiData]!.apiClientDoc!; DeleteUndefinedProps(req.body); @@ -184,7 +240,7 @@ router.patch( }); } - const newClient = await db["oauth2-clients"].findOneAndUpdate( + const newClient = await db["api-clients"].findOneAndUpdate( { clientID: client.clientID, }, @@ -194,7 +250,7 @@ router.patch( ); logger.info( - `OAuth2 Client ${client.name} (${client.clientID}) has been renamed to ${req.body.name}.` + `API Client ${client.name} (${client.clientID}) has been renamed to ${req.body.name}.` ); return res.status(200).json({ @@ -209,21 +265,21 @@ router.patch( * Resets the clientSecret for this client. * This will NOT invalidate any existing tokens, as per oauth2 spec. * - * @name POST /api/v1/oauth/clients/:clientID/reset-secret + * @name POST /api/v1/clients/:clientID/reset-secret */ router.post( "/:clientID/reset-secret", GetClientFromID, RequireOwnershipOfClient, async (req, res) => { - const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + const client = req[SYMBOL_TachiData]!.apiClientDoc!; const clientName = `${client.name} (${client.clientID})`; logger.info(`Recieved request to reset client secret for ${clientName}`); const newSecret = Random20Hex(); - const newClient = await db["oauth2-clients"].findOneAndUpdate( + const newClient = await db["api-clients"].findOneAndUpdate( { clientID: client.clientID, }, @@ -245,20 +301,20 @@ router.post( /** * Delete this client. Must be authorized at a session-request level. * - * @name DELETE /api/v1/oauth/clients/:clientID + * @name DELETE /api/v1/clients/:clientID */ router.delete("/:clientID", GetClientFromID, RequireOwnershipOfClient, async (req, res) => { - const client = req[SYMBOL_TachiData]!.oauth2ClientDoc!; + const client = req[SYMBOL_TachiData]!.apiClientDoc!; const clientName = `${client.name} (${client.clientID})`; - logger.info(`Recieved request to destroy OAuth2 Client ${client.name} (${client.clientID})`); + logger.info(`Recieved request to destroy API Client ${client.name} (${client.clientID})`); - logger.verbose(`Removing OAuth2 Client ${clientName}.`); - await db["oauth2-clients"].remove({ + logger.verbose(`Removing API Client ${clientName}.`); + await db["api-clients"].remove({ clientID: client.clientID, }); - logger.info(`Removed OAuth2 Client ${clientName}.`); + logger.info(`Removed API Client ${clientName}.`); logger.verbose(`Removing all associated api tokens.`); const result = await db["api-tokens"].remove({ diff --git a/server/src/server/router/api/v1/oauth/router.test.ts b/server/src/server/router/api/v1/oauth/router.test.ts index df5cd5e30..f55c9563e 100644 --- a/server/src/server/router/api/v1/oauth/router.test.ts +++ b/server/src/server/router/api/v1/oauth/router.test.ts @@ -23,7 +23,7 @@ t.test("POST /api/v1/oauth/token", (t) => { t.not(tokenDoc, null); t.equal(tokenDoc?.userID, 1); - t.equal(tokenDoc?.fromOAuth2Client, "OAUTH2_CLIENT_ID"); + t.equal(tokenDoc?.fromAPIClient, "OAUTH2_CLIENT_ID"); t.strictSame( tokenDoc?.permissions, { diff --git a/server/src/server/router/api/v1/oauth/router.ts b/server/src/server/router/api/v1/oauth/router.ts index 4cb9b9eb1..247679288 100644 --- a/server/src/server/router/api/v1/oauth/router.ts +++ b/server/src/server/router/api/v1/oauth/router.ts @@ -3,7 +3,6 @@ import db from "external/mongo/db"; import p from "prudence"; import prValidate from "server/middleware/prudence-validate"; import { Random20Hex } from "utils/misc"; -import clientsRouter from "./clients/router"; const router: Router = Router({ mergeParams: true }); @@ -32,7 +31,7 @@ router.post( code: "string", }), async (req, res) => { - const client = await db["oauth2-clients"].findOne({ + const client = await db["api-clients"].findOne({ clientID: req.body.client_id, }); @@ -74,7 +73,7 @@ router.post( identifier: `${client.name} Token`, // converts ["a","b"] to {a: true, b: true}. permissions: Object.fromEntries(client.requestedPermissions.map((e) => [e, true])), - fromOAuth2Client: client.clientID, + fromAPIClient: client.clientID, }; // Now we can actually register the api key (lol) @@ -114,6 +113,4 @@ router.post("/create-code", async (req, res) => { }); }); -router.use("/clients", clientsRouter); - export default router; diff --git a/server/src/server/router/api/v1/router.ts b/server/src/server/router/api/v1/router.ts index a88af6b67..08de29472 100644 --- a/server/src/server/router/api/v1/router.ts +++ b/server/src/server/router/api/v1/router.ts @@ -1,15 +1,16 @@ import { Router } from "express"; import adminRouter from "./admin/router"; import authRouter from "./auth/router"; +import clientsRouter from "./clients/router"; +import gamesRouter from "./games/router"; import importRouter from "./import/router"; +import importsRouter from "./imports/router"; +import oauthRouter from "./oauth/router"; +import scoresRouter from "./scores/router"; +import searchRouter from "./search/router"; +import sessionsRouter from "./sessions/router"; import statusRouter from "./status/router"; import usersRouter from "./users/router"; -import gamesRouter from "./games/router"; -import searchRouter from "./search/router"; -import scoresRouter from "./scores/router"; -import sessionsRouter from "./sessions/router"; -import oauthRouter from "./oauth/router"; -import importsRouter from "./imports/router"; const router: Router = Router({ mergeParams: true }); @@ -24,6 +25,7 @@ router.use("/search", searchRouter); router.use("/scores", scoresRouter); router.use("/sessions", sessionsRouter); router.use("/oauth", oauthRouter); +router.use("/clients", clientsRouter); /** * Return a JSON 404 response if an endpoint is hit that does not exist. diff --git a/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts b/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts index ace034571..53875460d 100644 --- a/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts +++ b/server/src/server/router/api/v1/users/_userID/api-tokens/router.ts @@ -65,10 +65,10 @@ router.post( const user = req[SYMBOL_TachiData]!.requestedUser!; let identifier: string; - let fromOAuth2Client; + let fromAPIClient; if (req.body.clientID) { - const client = await db["oauth2-clients"].findOne( + const client = await db["api-clients"].findOne( { clientID: req.body.clientID, }, @@ -101,7 +101,7 @@ router.post( permissions = client.requestedPermissions; identifier = client.name; - fromOAuth2Client = client.clientID; + fromAPIClient = client.clientID; logger.info( `Creating API Key for ${FormatUserDoc(user)} from ${client.name} specification.` @@ -127,7 +127,7 @@ router.post( permissions: permissionsObject, token: Random20Hex(), userID: user.id, - fromOAuth2Client, + fromAPIClient, }; await db["api-tokens"].insert(apiTokenDocument); diff --git a/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts index 45e028fb0..68ee92875 100644 --- a/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts +++ b/server/src/server/router/api/v1/users/_userID/integrations/kai/_kaiType/router.ts @@ -1,21 +1,19 @@ import { Router } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; import CreateLogCtx from "lib/logger/logger"; import { GetKaiTypeClientCredentials, KaiTypeToBaseURL, } from "lib/score-import/import-types/common/api-kai/utils"; -import prValidate from "server/middleware/prudence-validate"; -import { ValidateKaiType } from "./middleware"; import p from "prudence"; -import db from "external/mongo/db"; -import { SYMBOL_TachiData } from "lib/constants/tachi"; -import { FormatUserDoc } from "utils/user"; -import { GetKaiAuth } from "utils/queries/auth"; -import { RequireSelfRequestFromUser } from "../../../middleware"; +import prValidate from "server/middleware/prudence-validate"; import { RequireKamaitachi } from "server/middleware/type-require"; import fetch from "utils/fetch"; -import { Random20Hex } from "utils/misc"; -import { Environment } from "lib/setup/config"; +import { GetKaiAuth } from "utils/queries/auth"; +import { FormatUserDoc } from "utils/user"; +import { RequireSelfRequestFromUser } from "../../../middleware"; +import { ValidateKaiType } from "./middleware"; const router: Router = Router({ mergeParams: true }); diff --git a/server/src/test-utils/mock-db/api-clients.json b/server/src/test-utils/mock-db/api-clients.json new file mode 100644 index 000000000..a0dc28bd5 --- /dev/null +++ b/server/src/test-utils/mock-db/api-clients.json @@ -0,0 +1,15 @@ +[ + { + "clientID": "OAUTH2_CLIENT_ID", + "clientSecret": "OAUTH2_CLIENT_SECRET", + "name": "Test_Service", + "author": 1, + "requestedPermissions": [ + "customise_profile" + ], + "redirectUri": "https://example.com/callback", + "webhookUri": null, + "apiKeyTemplate": null, + "apiKeyFilename": null + } +] \ No newline at end of file diff --git a/server/src/test-utils/mock-db/oauth2-clients.json b/server/src/test-utils/mock-db/oauth2-clients.json deleted file mode 100644 index 017026de5..000000000 --- a/server/src/test-utils/mock-db/oauth2-clients.json +++ /dev/null @@ -1,9 +0,0 @@ -[{ - "clientID": "OAUTH2_CLIENT_ID", - "clientSecret": "OAUTH2_CLIENT_SECRET", - "name": "Test_Service", - "author": 1, - "requestedPermissions": ["customise_profile"], - "redirectUri": "https://example.com/callback", - "webhookUri": null -}] \ No newline at end of file diff --git a/server/src/utils/class.ts b/server/src/utils/class.ts index d1bf93ba4..1b857235f 100644 --- a/server/src/utils/class.ts +++ b/server/src/utils/class.ts @@ -72,7 +72,7 @@ export async function UpdateClassIfGreater( if (isGreater === null) { EmitWebhookEvent({ type: "class-update/v1", - content: { userID, new: classVal, old: null, set: classSet }, + content: { userID, new: classVal, old: null, set: classSet, game, playtype }, }); return null; @@ -84,6 +84,8 @@ export async function UpdateClassIfGreater( new: classVal, old: userGameStats!.classes[classSet]!, set: classSet, + game, + playtype, }, }); diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index c2ff9b7f8..3e02aa619 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -10,7 +10,7 @@ import { Playtypes, SongDocument, UserSettings, - OAuth2ApplicationDocument, + TachiAPIClientDocument, } from "tachi-common"; declare module "express-session" { @@ -63,5 +63,5 @@ export interface TachiRequestData { tableDoc?: TableDocument; folderDoc?: FolderDocument; - oauth2ClientDoc: Omit; + apiClientDoc: Omit; }