diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index a8e41ecb4..3605c8d7b 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -80,7 +80,7 @@ dependencies: rate-limit-redis: 2.1.0 redis: 3.1.2 rimraf: 3.0.2 - tachi-common: github.com/zkldi/tachi-common/0b3e388dd5f153e18a4cede1c9ba046f45c7a570_ts-node@10.0.0+typescript@4.3.4 + tachi-common: github.com/zkldi/tachi-common/478605bdec986f10af3af32be3bf7716ecec6d63_ts-node@10.0.0+typescript@4.3.4 typescript: 4.3.4 winston: 3.3.3 @@ -4233,9 +4233,9 @@ packages: engines: {node: '>=6'} dev: true - github.com/zkldi/tachi-common/0b3e388dd5f153e18a4cede1c9ba046f45c7a570_ts-node@10.0.0+typescript@4.3.4: - resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/0b3e388dd5f153e18a4cede1c9ba046f45c7a570} - id: github.com/zkldi/tachi-common/0b3e388dd5f153e18a4cede1c9ba046f45c7a570 + github.com/zkldi/tachi-common/478605bdec986f10af3af32be3bf7716ecec6d63_ts-node@10.0.0+typescript@4.3.4: + resolution: {tarball: https://codeload.github.com/zkldi/tachi-common/tar.gz/478605bdec986f10af3af32be3bf7716ecec6d63} + id: github.com/zkldi/tachi-common/478605bdec986f10af3af32be3bf7716ecec6d63 name: tachi-common version: 0.1.0 dependencies: diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index 106782651..e3c90e759 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -62,7 +62,7 @@ monkDB }); const RemoveIDMiddleware: TMiddleware = - ({ collection, monkInstance }) => + () => (next) => // eslint-disable-next-line @typescript-eslint/no-explicit-any (args: any, method) => { diff --git a/server/src/server/middleware/auth.ts b/server/src/server/middleware/auth.ts index e2fee4a9c..143fdb1b1 100644 --- a/server/src/server/middleware/auth.ts +++ b/server/src/server/middleware/auth.ts @@ -17,6 +17,9 @@ const GuestToken: APITokenDocument = { export const AllPermissions: Record = { customise_profile: true, submit_score: true, + customise_session: true, + customise_score: true, + delete_score: true, }; /** @@ -31,7 +34,7 @@ export const AllPermissions: Record = { * If this request was made with no auth headers or session tokens, then a guest * token is set as the request token, with no permissions. * - * This is set on req[SYMBOL_TachiAPIData]. + * This is set on req[SYMBOL_TachiAPIAuth]. */ export const SetRequestPermissions: RequestHandler = async (req, res, next) => { if (req.session?.tachi?.userID) { @@ -98,7 +101,9 @@ export const RequirePermissions = (...perms: APIPermissions[]): RequestHandler => (req, res, next) => { if (!req[SYMBOL_TachiAPIAuth]) { - logger.error(`RequirePermissions middleware was hit without any TachiAPIData?`); + logger.error( + `RequirePermissions middleware was hit without any TachiAPIAuthentication?` + ); return res.status(500).json({ success: false, diff --git a/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts b/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts index 6418dcac1..d81c2b99e 100644 --- a/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts +++ b/server/src/server/router/api/v1/games/_game/_playtype/charts/_chartID/router.ts @@ -2,7 +2,8 @@ import { Router } from "express"; import db from "../../../../../../../../../external/mongo/db"; import { SYMBOL_TachiData } from "../../../../../../../../../lib/constants/tachi"; import CreateLogCtx from "../../../../../../../../../lib/logger/logger"; -import { FormatChart } from "../../../../../../../../../utils/misc"; +import { SearchUsersRegExp } from "../../../../../../../../../lib/search/search"; +import { FormatChart, IsString } from "../../../../../../../../../utils/misc"; import { ParseStrPositiveNonZeroInt } from "../../../../../../../../../utils/string-checks"; import { GetUsersWithIDs } from "../../../../../../../../../utils/user"; import { ValidateAndGetChart } from "./middleware"; @@ -104,4 +105,38 @@ router.get("/pbs", async (req, res) => { }); }); +/** + * Searches the PBs on this chart for the given user(s). + * + * @param search - The user to search for + * + * @name GET /api/v1/games/:game/:playtype/charts/:chartID/pbs/search + */ +router.get("/pbs/search", async (req, res) => { + const chart = req[SYMBOL_TachiData]!.chartDoc!; + + if (!IsString(req.query.search)) { + return res.status(400).json({ + success: false, + description: `Invalid parameter for search.`, + }); + } + + const users = await SearchUsersRegExp(req.query.search); + + const pbs = await db["personal-bests"].find({ + chartID: chart.chartID, + userID: { $in: users.map((e) => e.id) }, + }); + + return res.status(200).json({ + success: true, + description: `Returned ${pbs.length} scores.`, + body: { + pbs, + users, + }, + }); +}); + export default router; diff --git a/server/src/server/router/api/v1/scores/_scoreID/middleware.ts b/server/src/server/router/api/v1/scores/_scoreID/middleware.ts index 922c2831e..314d1744a 100644 --- a/server/src/server/router/api/v1/scores/_scoreID/middleware.ts +++ b/server/src/server/router/api/v1/scores/_scoreID/middleware.ts @@ -1,5 +1,6 @@ import { RequestHandler } from "express"; import db from "../../../../../../external/mongo/db"; +import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "../../../../../../lib/constants/tachi"; import { AssignToReqTachiData } from "../../../../../../utils/req-tachi-data"; export const GetScoreFromParam: RequestHandler = async (req, res, next) => { @@ -20,4 +21,20 @@ export const GetScoreFromParam: RequestHandler = async (req, res, next) => { } AssignToReqTachiData(req, { scoreDoc: score }); + + return next(); +}; + +export const RequireOwnershipOfScore: RequestHandler = (req, res, next) => { + const score = req[SYMBOL_TachiData]!.scoreDoc!; + const userID = req[SYMBOL_TachiAPIAuth].userID!; + + if (score.userID !== userID) { + return res.status(403).json({ + success: false, + description: `You are not authorised to perform this action.`, + }); + } + + return next(); }; diff --git a/server/src/server/router/api/v1/scores/_scoreID/router.ts b/server/src/server/router/api/v1/scores/_scoreID/router.ts index 7d5e96bae..800169385 100644 --- a/server/src/server/router/api/v1/scores/_scoreID/router.ts +++ b/server/src/server/router/api/v1/scores/_scoreID/router.ts @@ -3,7 +3,10 @@ import db from "../../../../../../external/mongo/db"; import { SYMBOL_TachiData } from "../../../../../../lib/constants/tachi"; import CreateLogCtx from "../../../../../../lib/logger/logger"; import { GetUserWithID } from "../../../../../../utils/user"; -import { GetScoreFromParam } from "./middleware"; +import { RequirePermissions } from "../../../../../middleware/auth"; +import prValidate from "../../../../../middleware/prudence-validate"; +import { GetScoreFromParam, RequireOwnershipOfScore } from "./middleware"; +import p from "prudence"; const router: Router = Router({ mergeParams: true }); @@ -62,4 +65,57 @@ router.get("/", async (req, res) => { }); }); +interface ModifiableScoreProps { + comment?: string | null; + highlight?: boolean; +} + +/** + * Modifies a score. + * + * Requires you to be the owner of this score, and have the modify_scores permission. + * + * @name PATCH /api/v1/scores/:scoreID + */ +router.patch( + "/", + RequireOwnershipOfScore, + RequirePermissions("customise_score"), + prValidate({ + comment: p.optional(p.nullable(p.isBoundedString(1, 140))), + highlight: "*boolean", + }), + async (req, res) => { + const score = req[SYMBOL_TachiData]!.scoreDoc!; + + const modifyOption: ModifiableScoreProps = {}; + + if (req.body.comment !== undefined) { + modifyOption.comment = req.body.comment; + } + + if (req.body.comment !== undefined) { + modifyOption.highlight = req.body.highlight; + } + + if (Object.keys(modifyOption).length === 0) { + return res.status(400).json({ + success: false, + description: `This request modifies nothing about the score.`, + }); + } + + const newScore = await db.scores.findOneAndUpdate( + { scoreID: score.scoreID }, + { $set: modifyOption } + ); + + return res.status(200).json({ + success: true, + description: `Updated score.`, + body: newScore, + }); + } +); + export default router; diff --git a/server/src/server/router/api/v1/scores/router.ts b/server/src/server/router/api/v1/scores/router.ts index e348e3890..e9f5073f6 100644 --- a/server/src/server/router/api/v1/scores/router.ts +++ b/server/src/server/router/api/v1/scores/router.ts @@ -4,11 +4,11 @@ import scoreIDRouter from "./_scoreID/router"; const router: Router = Router({ mergeParams: true }); /** - * No idea yet. + * Nothing? Maybe we can think of a good use for this endpoint at some point. * - * @name GET /api/v1/scores + * @name GET /api/v1/scores/:scoreID */ -router.get("/", async (req, res) => {}); +// router.get("/", async (req, res) => {}); router.use("/:scoreID", scoreIDRouter); diff --git a/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts b/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts new file mode 100644 index 000000000..619107075 --- /dev/null +++ b/server/src/server/router/api/v1/sessions/_sessionID/middleware.ts @@ -0,0 +1,35 @@ +import { RequestHandler } from "express"; +import db from "../../../../../../external/mongo/db"; +import { SYMBOL_TachiAPIAuth, SYMBOL_TachiData } from "../../../../../../lib/constants/tachi"; +import { AssignToReqTachiData } from "../../../../../../utils/req-tachi-data"; + +export const GetSessionFromParam: RequestHandler = async (req, res, next) => { + const session = await db.sessions.findOne({ + sessionID: req.params.sessionID, + }); + + if (!session) { + return res.status(404).json({ + success: false, + description: `This session does not exist.`, + }); + } + + AssignToReqTachiData(req, { sessionDoc: session }); + + return next(); +}; + +export const RequireOwnershipOfSession: RequestHandler = (req, res, next) => { + const userID = req[SYMBOL_TachiAPIAuth].userID; + const session = req[SYMBOL_TachiData]!.sessionDoc!; + + if (userID !== session.userID) { + return res.status(403).json({ + success: false, + description: `You are not authorised to modify this session.`, + }); + } + + return next(); +}; diff --git a/server/src/server/router/api/v1/sessions/_sessionID/router.ts b/server/src/server/router/api/v1/sessions/_sessionID/router.ts new file mode 100644 index 000000000..b897e9419 --- /dev/null +++ b/server/src/server/router/api/v1/sessions/_sessionID/router.ts @@ -0,0 +1,112 @@ +import { Router } from "express"; +import db from "../../../../../../external/mongo/db"; +import { SYMBOL_TachiData } from "../../../../../../lib/constants/tachi"; +import { RequirePermissions } from "../../../../../middleware/auth"; +import prValidate from "../../../../../middleware/prudence-validate"; +import p from "prudence"; +import { RequireOwnershipOfSession } from "./middleware"; + +const router: Router = Router({ mergeParams: true }); + +/** + * Retrieves the session, its scores and the related songs and charts. + * + * @name GET /api/v1/sessions/:sessionID + */ +router.get("/", async (req, res) => { + const session = req[SYMBOL_TachiData]!.sessionDoc!; + + const scores = await db.scores.find({ + scoreID: { $in: session.scoreInfo.map((e) => e.scoreID) }, + }); + + const [songs, charts] = await Promise.all([ + db.songs[session.game].find({ + id: { $in: scores.map((e) => e.songID) }, + }), + db.charts[session.game].find({ + chartID: { $in: scores.map((e) => e.chartID) }, + }), + ]); + + return res.status(200).json({ + success: true, + description: `Successfully returned session ${session.name}.`, + body: { + session, + songs, + charts, + scores, + }, + }); +}); + +interface ModifiableSessionProps { + name?: string; + desc?: string; + highlight?: boolean; +} + +/** + * Modifies a session. + * + * Requires the requester to be the owner of the session, alongside having the + * customise_session permission. + * + * @param name - A new name for the session. + * @param desc - A new desc for the session. + * @param highlight - Update the highlighted state of the session with this. + * + * @name PATCH /api/v1/sessions/:sessionID + */ +router.patch( + "/", + RequireOwnershipOfSession, + RequirePermissions("customise_session"), + prValidate( + { + name: p.optional(p.isBoundedString(3, 80)), + desc: p.optional(p.isBoundedString(3, 120)), + highlight: "*boolean", + }, + {}, + { allowExcessKeys: true } + ), + async (req, res) => { + const session = req[SYMBOL_TachiData]!.sessionDoc!; + + const updateExp: ModifiableSessionProps = {}; + + if (req.body.name) { + updateExp.name = req.body.name as string; + } + + if (req.query.desc) { + updateExp.desc = req.body.desc as string; + } + + if (typeof req.body.highlight === "boolean") { + updateExp.highlight = req.body.highlight as boolean; + } + + if (Object.keys(updateExp).length === 0) { + return res.status(400).json({ + success: false, + description: `This request modifies nothing about this session.`, + }); + } + + const newSession = await db.sessions.findOneAndUpdate( + { sessionID: session.sessionID }, + { $set: updateExp } + ); + + return res.status(200).json({ + success: true, + description: `Updated Session.`, + body: newSession, + }); + } +); + +export default router; diff --git a/server/src/server/router/api/v1/sessions/router.ts b/server/src/server/router/api/v1/sessions/router.ts new file mode 100644 index 000000000..a85d42c7e --- /dev/null +++ b/server/src/server/router/api/v1/sessions/router.ts @@ -0,0 +1,12 @@ +import { Router } from "express"; + +const router: Router = Router({ mergeParams: true }); + +/** + * nothing, yet. + * + * @name GET /api/v1/sessions + */ +// router.get("/", async (req, res) => {}); + +export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts new file mode 100644 index 000000000..e1565b973 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.test.ts @@ -0,0 +1,145 @@ +import t from "tap"; +import db from "../../../../../../../../../../external/mongo/db"; +import { CloseAllConnections } from "../../../../../../../../../../test-utils/close-connections"; +import mockApi from "../../../../../../../../../../test-utils/mock-api"; +import ResetDBState from "../../../../../../../../../../test-utils/resets"; +import { + Testing511Song, + LoadKTBlackIIDXData, + GetKTDataJSON, +} from "../../../../../../../../../../test-utils/test-data"; +import { PBScoreDocument } from "tachi-common"; + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs/best", (t) => { + t.beforeEach(ResetDBState); + + t.test("Should return a users best 100 personal bests.", async (t) => { + const mockPBs: PBScoreDocument[] = []; + + for (let i = 0; i < 200; i++) { + mockPBs.push({ + userID: 1, + game: "iidx", + playtype: "SP", + isPrimary: true, + chartID: i.toString(), // hack to generate some random chartIDs + songID: Testing511Song.id, + calculatedData: { + ktRating: i, + }, + } as PBScoreDocument); + } + + await db["personal-bests"].insert(mockPBs); + + for (const sc of mockPBs) { + delete sc._id; // lol + } + + const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs/best"); + + t.hasStrict(res.body, { + success: true, + description: "Retrieved 100 personal bests.", + body: { + scores: mockPBs.slice(100).reverse(), + songs: [Testing511Song], + charts: [], + }, + }); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadKTBlackIIDXData); + + t.test("Should return 400 if no search param is given", async (t) => { + const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs"); + + t.equal(res.statusCode, 400); + t.equal(res.body.success, false); + + t.end(); + }); + + t.test("Should return 400 if invalid search param is given", async (t) => { + const res = await mockApi.get( + "/api/v1/users/test_zkldi/games/iidx/SP/pbs?search=foo&search=bar" + ); + + t.equal(res.statusCode, 400); + t.equal(res.body.success, false); + + const res2 = await mockApi.get( + "/api/v1/users/test_zkldi/games/iidx/SP/pbs?search[$where]=process.exit(1)" + ); + + t.equal(res2.statusCode, 400); + t.equal(res2.body.success, false); + + t.end(); + }); + + t.test("Should search a user's personal bests.", async (t) => { + const mockPBs: PBScoreDocument[] = []; + + const charts = GetKTDataJSON("./tachi/ktblack-charts-iidx.json"); + + for (let i = 0; i < 200; i++) { + mockPBs.push({ + userID: 1, + game: "iidx", + playtype: "SP", + isPrimary: true, + chartID: charts[i].chartID, + songID: charts[i].songID, + calculatedData: { + ktRating: i, + }, + } as PBScoreDocument); + } + + await db["personal-bests"].insert(mockPBs); + + const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs?search=5.1.1."); + + t.hasStrict(res.body, { + success: true, + description: "Retrieved 2 personal bests.", + body: { + pbs: [ + { + chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", + }, + { + chartID: "c641238220d73faf82659513ba03bde71b0b45f0", + }, + ], + songs: [ + { + title: "5.1.1.", + }, + ], + charts: [ + { + chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", + }, + { + chartID: "c641238220d73faf82659513ba03bde71b0b45f0", + }, + ], + }, + }); + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts index 327b89797..722911d59 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.test.ts @@ -4,13 +4,11 @@ import { CloseAllConnections } from "../../../../../../../../../test-utils/close import mockApi from "../../../../../../../../../test-utils/mock-api"; import ResetDBState, { SetIndexesForDB } from "../../../../../../../../../test-utils/resets"; import { - SessionDocument, ScoreDocument, UserGoalDocument, GoalDocument, MilestoneDocument, UserMilestoneDocument, - PBScoreDocument, } from "tachi-common"; import { GetKTDataJSON, @@ -299,138 +297,6 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores/recent", (t) => { t.end(); }); -t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs/best", (t) => { - t.beforeEach(ResetDBState); - - t.test("Should return a users best 100 personal bests.", async (t) => { - const mockPBs: PBScoreDocument[] = []; - - for (let i = 0; i < 200; i++) { - mockPBs.push({ - userID: 1, - game: "iidx", - playtype: "SP", - isPrimary: true, - chartID: i.toString(), // hack to generate some random chartIDs - songID: Testing511Song.id, - calculatedData: { - ktRating: i, - }, - } as PBScoreDocument); - } - - await db["personal-bests"].insert(mockPBs); - - for (const sc of mockPBs) { - delete sc._id; // lol - } - - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs/best"); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 100 personal bests.", - body: { - scores: mockPBs.slice(100).reverse(), - songs: [Testing511Song], - charts: [], - }, - }); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/pbs", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadKTBlackIIDXData); - - t.test("Should return 400 if no search param is given", async (t) => { - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs"); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.test("Should return 400 if invalid search param is given", async (t) => { - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/pbs?search=foo&search=bar" - ); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - const res2 = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/pbs?search[$where]=process.exit(1)" - ); - - t.equal(res2.statusCode, 400); - t.equal(res2.body.success, false); - - t.end(); - }); - - t.test("Should search a user's personal bests.", async (t) => { - const mockPBs: PBScoreDocument[] = []; - - const charts = GetKTDataJSON("./tachi/ktblack-charts-iidx.json"); - - for (let i = 0; i < 200; i++) { - mockPBs.push({ - userID: 1, - game: "iidx", - playtype: "SP", - isPrimary: true, - chartID: charts[i].chartID, - songID: charts[i].songID, - calculatedData: { - ktRating: i, - }, - } as PBScoreDocument); - } - - await db["personal-bests"].insert(mockPBs); - - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/pbs?search=5.1.1."); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 2 personal bests.", - body: { - pbs: [ - { - chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", - }, - { - chartID: "c641238220d73faf82659513ba03bde71b0b45f0", - }, - ], - songs: [ - { - title: "5.1.1.", - }, - ], - charts: [ - { - chartID: "c2311194e3897ddb5745b1760d2c0141f933e683", - }, - { - chartID: "c641238220d73faf82659513ba03bde71b0b45f0", - }, - ], - }, - }); - - t.end(); - }); - - t.end(); -}); - t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores", (t) => { t.beforeEach(ResetDBState); t.beforeEach(LoadKTBlackIIDXData); @@ -530,111 +396,6 @@ t.test("GET /api/v1/users/:userID/games/:game/:playtype/scores", (t) => { t.end(); }); -t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadKTBlackIIDXData); - - t.test("Should return 400 if no search param is given", async (t) => { - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/sessions"); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - t.end(); - }); - - t.test("Should return 400 if invalid search param is given", async (t) => { - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search=foo&search=bar" - ); - - t.equal(res.statusCode, 400); - t.equal(res.body.success, false); - - // evil eval attempts - const res2 = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search[$where]=process.exit(1)" - ); - - t.equal(res2.statusCode, 400); - t.equal(res2.body.success, false); - - t.end(); - }); - - t.test("Should search a user's sessions.", async (t) => { - await db.sessions.insert( - ["Epic Session", "Session Of Epic", "Epic Gaming", "something else", "bad session"].map( - (e) => ({ - userID: 1, - game: "iidx", - playtype: "SP", - name: e, - desc: "something", - sessionID: e, // hack to avoid db nonsense - }) - ) as SessionDocument[] - ); - - const res = await mockApi.get( - "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search=Epic" - ); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 3 sessions.", - body: [], - }); - - t.end(); - }); - - t.end(); -}); - -t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/best", (t) => { - t.beforeEach(ResetDBState); - t.beforeEach(LoadKTBlackIIDXData); - - t.test("Should return a user's best 100 sessions.", async (t) => { - const sessions: SessionDocument[] = []; - - for (let i = 0; i < 200; i++) { - sessions.push({ - sessionID: i.toString(), - game: "iidx", - playtype: "SP", - userID: 1, - calculatedData: { - ktRating: i, - }, - } as SessionDocument); - } - - await db.sessions.remove({}); - await db.sessions.insert(sessions); - - const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/sessions/best"); - - t.hasStrict(res.body, { - success: true, - description: "Retrieved 100 sessions.", - }); - - t.strictSame( - res.body.body.map((e: SessionDocument) => e.sessionID), - sessions - .slice(100) - .reverse() - .map((e) => e.sessionID) - ); - - t.end(); - }); - - t.end(); -}); - t.test("GET /api/v1/users/:userID/games/:game/:playtype/most-played", (t) => { t.beforeEach(ResetDBState); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts index 9051138f1..11a51d9d1 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/router.ts @@ -12,14 +12,12 @@ import { integer, PBScoreDocument, } from "tachi-common"; -import { - SearchGameSongsAndCharts, - SearchSessions, -} from "../../../../../../../../../lib/search/search"; +import { SearchGameSongsAndCharts } from "../../../../../../../../../lib/search/search"; import { FilterChartsAndSongs } from "../../../../../../../../../utils/scores"; import { CheckStrProfileAlg } from "../../../../../../../../../utils/string-checks"; import { IsString } from "../../../../../../../../../utils/misc"; import pbsRouter from "./pbs/router"; +import sessionsRouter from "./sessions/router"; const router: Router = Router({ mergeParams: true }); @@ -243,67 +241,6 @@ router.get("/scores/recent", async (req, res) => { }); }); -/** - * Search a users sessions. - * - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions - */ -router.get("/sessions", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; - - if (typeof req.query.search !== "string") { - return res.status(400).json({ - success: false, - description: `Invalid value of ${req.query.search} for search parameter.`, - }); - } - - const sessions = await SearchSessions(req.query.search, game, playtype, user.id, 100); - - return res.status(200).json({ - success: true, - description: `Retrieved ${sessions.length} sessions.`, - body: sessions, - }); -}); - -/** - * Returns a user's best 100 sessions according to the default statistic - * for that game. - * - * @param alg - An override to specify a different algorithm for that game. - * UNIMPLEMENTED!!! - * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/best - */ -router.get("/sessions/best", async (req, res) => { - const user = req[SYMBOL_TachiData]!.requestedUser!; - const game = req[SYMBOL_TachiData]!.game!; - const playtype = req[SYMBOL_TachiData]!.playtype!; - const gptConfig = GetGamePTConfig(game, playtype); - - const sessions = await db.sessions.find( - { - userID: user.id, - game, - playtype, - }, - { - limit: 100, - sort: { - [`calculatedData.${gptConfig.defaultSessionRatingAlg}`]: -1, - }, - } - ); - - return res.status(200).json({ - success: true, - description: `Retrieved ${sessions.length} sessions.`, - body: sessions, - }); -}); - /** * Returns the users most played charts by playcount. * @@ -458,5 +395,6 @@ router.get("/leaderboard-adjacent", async (req, res) => { }); router.use("/pbs", pbsRouter); +router.use("/sessions", sessionsRouter); export default router; diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts new file mode 100644 index 000000000..0fa56e08c --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.test.ts @@ -0,0 +1,114 @@ +import t from "tap"; +import db from "../../../../../../../../../../external/mongo/db"; +import { CloseAllConnections } from "../../../../../../../../../../test-utils/close-connections"; +import mockApi from "../../../../../../../../../../test-utils/mock-api"; +import ResetDBState from "../../../../../../../../../../test-utils/resets"; +import { LoadKTBlackIIDXData } from "../../../../../../../../../../test-utils/test-data"; +import { SessionDocument } from "tachi-common"; + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadKTBlackIIDXData); + + t.test("Should return 400 if no search param is given", async (t) => { + const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/sessions"); + + t.equal(res.statusCode, 400); + t.equal(res.body.success, false); + + t.end(); + }); + + t.test("Should return 400 if invalid search param is given", async (t) => { + const res = await mockApi.get( + "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search=foo&search=bar" + ); + + t.equal(res.statusCode, 400); + t.equal(res.body.success, false); + + // evil eval attempts + const res2 = await mockApi.get( + "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search[$where]=process.exit(1)" + ); + + t.equal(res2.statusCode, 400); + t.equal(res2.body.success, false); + + t.end(); + }); + + t.test("Should search a user's sessions.", async (t) => { + await db.sessions.insert( + ["Epic Session", "Session Of Epic", "Epic Gaming", "something else", "bad session"].map( + (e) => ({ + userID: 1, + game: "iidx", + playtype: "SP", + name: e, + desc: "something", + sessionID: e, // hack to avoid db nonsense + }) + ) as SessionDocument[] + ); + + const res = await mockApi.get( + "/api/v1/users/test_zkldi/games/iidx/SP/sessions?search=Epic" + ); + + t.hasStrict(res.body, { + success: true, + description: "Retrieved 3 sessions.", + body: [], + }); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/users/:userID/games/:game/:playtype/sessions/best", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(LoadKTBlackIIDXData); + + t.test("Should return a user's best 100 sessions.", async (t) => { + const sessions: SessionDocument[] = []; + + for (let i = 0; i < 200; i++) { + sessions.push({ + sessionID: i.toString(), + game: "iidx", + playtype: "SP", + userID: 1, + calculatedData: { + ktRating: i, + }, + } as SessionDocument); + } + + await db.sessions.remove({}); + await db.sessions.insert(sessions); + + const res = await mockApi.get("/api/v1/users/test_zkldi/games/iidx/SP/sessions/best"); + + t.hasStrict(res.body, { + success: true, + description: "Retrieved 100 sessions.", + }); + + t.strictSame( + res.body.body.map((e: SessionDocument) => e.sessionID), + sessions + .slice(100) + .reverse() + .map((e) => e.sessionID) + ); + + t.end(); + }); + + t.end(); +}); + +t.teardown(CloseAllConnections); diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts new file mode 100644 index 000000000..f2f940312 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/sessions/router.ts @@ -0,0 +1,92 @@ +import { Router } from "express"; +import db from "../../../../../../../../../../external/mongo/db"; +import { SYMBOL_TachiData } from "../../../../../../../../../../lib/constants/tachi"; +import { SearchSessions } from "../../../../../../../../../../lib/search/search"; +import { GetGamePTConfig } from "tachi-common"; + +const router: Router = Router({ mergeParams: true }); + +/** + * Search a users sessions. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions + */ +router.get("/", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + + if (typeof req.query.search !== "string") { + return res.status(400).json({ + success: false, + description: `Invalid value of ${req.query.search} for search parameter.`, + }); + } + + const sessions = await SearchSessions(req.query.search, game, playtype, user.id, 100); + + return res.status(200).json({ + success: true, + description: `Retrieved ${sessions.length} sessions.`, + body: sessions, + }); +}); + +/** + * Returns a user's best 100 sessions according to the default statistic + * for that game. + * + * @param alg - An override to specify a different algorithm for that game. + * UNIMPLEMENTED!!! + * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/best + */ +router.get("/best", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + const gptConfig = GetGamePTConfig(game, playtype); + + const sessions = await db.sessions.find( + { + userID: user.id, + game, + playtype, + }, + { + limit: 100, + sort: { + [`calculatedData.${gptConfig.defaultSessionRatingAlg}`]: -1, + }, + } + ); + + return res.status(200).json({ + success: true, + description: `Retrieved ${sessions.length} sessions.`, + body: sessions, + }); +}); + +/** + * Returns a users 100 most recent highlighted sessions. Returned in timeEnded order. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/sessions/highlighted + */ +router.get("/highlighted", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + + const sessions = await db.sessions.find( + { userID: user.id, game, playtype, highlight: true }, + { sort: { timeEnded: -1 }, limit: 100 } + ); + + return res.status(200).json({ + success: true, + description: `Returned ${sessions.length} sessions.`, + body: sessions, + }); +}); + +export default router; diff --git a/server/src/utils/types.ts b/server/src/utils/types.ts index c0b76eca9..ded715ed6 100644 --- a/server/src/utils/types.ts +++ b/server/src/utils/types.ts @@ -1,5 +1,6 @@ import { integer, + SessionDocument, ScoreDocument, ChartDocument, PublicUserDocument, @@ -55,4 +56,5 @@ export interface TachiRequestData { chartDoc?: AnyChartDocument; songDoc?: AnySongDocument; scoreDoc?: ScoreDocument; + sessionDoc?: SessionDocument; }