From 466b2dd64e6a09fe7fd2afcb2caa0ddce909cea2 Mon Sep 17 00:00:00 2001 From: zkldi Date: Sun, 22 Aug 2021 06:07:43 +0100 Subject: [PATCH] Add PATCH /users/:userID endpoint --- server/package.json | 2 +- server/pnpm-lock.yaml | 8 +- server/src/server/router/api/v1/auth/auth.ts | 1 + .../games/_game/_playtype/pbs/router.ts | 31 ++- .../router/api/v1/users/_userID/middleware.ts | 7 + .../api/v1/users/_userID/router.test.ts | 181 ++++++++++++++++++ .../router/api/v1/users/_userID/router.ts | 117 ++++++++++- server/src/utils/misc.ts | 22 ++- server/src/utils/prudence.ts | 2 + 9 files changed, 363 insertions(+), 8 deletions(-) diff --git a/server/package.json b/server/package.json index 18fbf74bb..5a324be57 100644 --- a/server/package.json +++ b/server/package.json @@ -71,7 +71,7 @@ "redis": "3.1.2", "rimraf": "3.0.2", "safe-json-stringify": "1.2.0", - "tachi-common": "0.1.38", + "tachi-common": "0.1.40", "typescript": "4.3.4", "winston": "3.3.3" }, diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 79a474ef7..4f1ae92ee 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -52,7 +52,7 @@ specifiers: rimraf: 3.0.2 safe-json-stringify: 1.2.0 supertest: 6.1.3 - tachi-common: 0.1.38 + tachi-common: 0.1.40 tap: 15.0.9 ts-node: 10.0.0 tsconfig-paths: 3.10.1 @@ -85,7 +85,7 @@ dependencies: redis: 3.1.2 rimraf: 3.0.2 safe-json-stringify: 1.2.0 - tachi-common: 0.1.38_ts-node@10.0.0+typescript@4.3.4 + tachi-common: 0.1.40_ts-node@10.0.0+typescript@4.3.4 typescript: 4.3.4 winston: 3.3.3 @@ -3666,8 +3666,8 @@ packages: strip-ansi: 6.0.0 dev: true - /tachi-common/0.1.38_ts-node@10.0.0+typescript@4.3.4: - resolution: {integrity: sha512-i+6A31CrE9S3K5Ht8KN1LdryM4An8tmtLwlZt4bszAVzt8i4wiVo7yxHxDvPWSk7aGFFTyaVpabbycAyYCa+lg==} + /tachi-common/0.1.40_ts-node@10.0.0+typescript@4.3.4: + resolution: {integrity: sha512-oj52sHcb2x+Jcg/qyKx1ras/b/9AiW5n1yYa+1pw/6SD8ZcHtzXkSFnUhq0oFbcwPvfy+4m7ygczMfhw9BsN2g==} dependencies: monk: 7.3.4 tap: 15.0.9_ts-node@10.0.0+typescript@4.3.4 diff --git a/server/src/server/router/api/v1/auth/auth.ts b/server/src/server/router/api/v1/auth/auth.ts index 86865e30e..e49af1224 100644 --- a/server/src/server/router/api/v1/auth/auth.ts +++ b/server/src/server/router/api/v1/auth/auth.ts @@ -90,6 +90,7 @@ export async function AddNewUser( email: email, clan: null, socialMedia: {}, + status: null, customBanner: false, customPfp: false, joinDate: Date.now(), diff --git a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts index 94aef3e1a..1919b88f2 100644 --- a/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts +++ b/server/src/server/router/api/v1/users/_userID/games/_game/_playtype/pbs/router.ts @@ -11,7 +11,7 @@ const router: Router = Router({ mergeParams: true }); /** * Searches a user's personal bests. * - * @name GET /api/v1/users/:userID/games/:game/:playtype/scores + * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs */ router.get("/", async (req, res) => { const user = req[SYMBOL_TachiData]!.requestedUser!; @@ -57,6 +57,35 @@ router.get("/", async (req, res) => { }); }); +/** + * Returns all of a users personal bests. + * + * @warn This endpoint is probably quite expensive. We'll need to do + * some performance tests. + * + * @name GET /api/v1/users/:userID/games/:game/:playtype/pbs/all + */ +router.get("/all", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + const game = req[SYMBOL_TachiData]!.game!; + const playtype = req[SYMBOL_TachiData]!.playtype!; + + const pbs = await db["personal-bests"].find({ + userID: user.id, + game, + playtype, + isPrimary: true, + }); + + const { songs, charts } = await GetRelevantSongsAndCharts(pbs, game); + + return res.status(200).json({ + success: true, + description: `Returned ${pbs.length} PBs.`, + body: { pbs, songs, charts }, + }); +}); + /** * Returns a users best 100 personal-bests for this game. * diff --git a/server/src/server/router/api/v1/users/_userID/middleware.ts b/server/src/server/router/api/v1/users/_userID/middleware.ts index 672a17dab..2394ced99 100644 --- a/server/src/server/router/api/v1/users/_userID/middleware.ts +++ b/server/src/server/router/api/v1/users/_userID/middleware.ts @@ -44,6 +44,13 @@ export const GetUserFromParam: RequestHandler = async (req, res, next) => { export const RequireAuthedAsUser: RequestHandler = (req, res, next) => { const user = req[SYMBOL_TachiData]!.requestedUser!; + if (!req[SYMBOL_TachiAPIAuth].userID) { + return res.status(401).json({ + success: false, + description: `Authentication is required for this endpoint.`, + }); + } + if (req[SYMBOL_TachiAPIAuth].userID !== user.id) { return res.status(403).json({ success: false, diff --git a/server/src/server/router/api/v1/users/_userID/router.test.ts b/server/src/server/router/api/v1/users/_userID/router.test.ts index 9700eebca..f47c77523 100644 --- a/server/src/server/router/api/v1/users/_userID/router.test.ts +++ b/server/src/server/router/api/v1/users/_userID/router.test.ts @@ -69,6 +69,187 @@ t.test("GET /api/v1/users/:userID", (t) => { t.end(); }); +t.test("PATCH /api/v1/users/:userID", (t) => { + t.beforeEach(ResetDBState); + t.beforeEach(async () => { + await db["api-tokens"].insert({ + identifier: "customiseProfile", + permissions: { + customise_profile: true, + }, + token: "valid_token", + userID: 1, + }); + }); + + t.test("Should require authentication", async (t) => { + const res = await mockApi.patch("/api/v1/users/1"); + + t.equal(res.statusCode, 401); + + await db["api-tokens"].insert({ + token: "noperm", + permissions: {}, + identifier: "No permissions token", + userID: 1, + }); + + const res2 = await mockApi.patch("/api/v1/users/1").set("Authorization", "Bearer noperm"); + + t.equal(res2.statusCode, 403); + + t.end(); + }); + + t.test("Should reject empty updates.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token"); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.test("Should update the user doc.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token") + .send({ + status: "Hello World!", + }); + + t.equal(res.body.body.status, "Hello World!"); + + const dbUser = await db.users.findOne({ id: 1 }); + + t.equal(dbUser?.status, "Hello World!"); + + t.end(); + }); + + t.test("Shouldn't alter other properties.", async (t) => { + await db.users.update({ id: 1 }, { $set: { "socialMedia.discord": "foo#123" } }); + + const res = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token") + .send({ + status: "Hello World!", + }); + + t.equal(res.body.body.status, "Hello World!"); + + const dbUser = await db.users.findOne({ id: 1 }); + + t.equal(dbUser?.status, "Hello World!"); + t.equal(dbUser?.about, "test_user_not_real"); + t.equal(dbUser?.socialMedia.discord, "foo#123"); + + t.end(); + }); + + t.test("Should correctly strip twitter urls.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token") + .send({ + twitter: "https://twitter.com/zkldi", + }); + + t.equal(res.body.body.socialMedia.twitter, "zkldi"); + + const dbUser = await db.users.findOne({ id: 1 }); + + t.equal(dbUser?.socialMedia.twitter, "zkldi"); + + t.end(); + }); + + t.test("Should correctly strip youtube urls.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token") + .send({ + youtube: "https://youtube.com/user/zkldi", + }); + + t.equal(res.body.body.socialMedia.youtube, "zkldi"); + + const dbUser = await db.users.findOne({ id: 1 }); + + t.equal(dbUser?.socialMedia.youtube, "zkldi"); + + const res2 = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token") + .send({ + youtube: "https://youtube.com/channel/zkldi", + }); + + t.equal(res2.body.body.socialMedia.youtube, "zkldi"); + + const dbUser2 = await db.users.findOne({ id: 1 }); + + t.equal(dbUser2?.socialMedia.youtube, "zkldi"); + + t.end(); + }); + + t.test("Should correctly strip github urls.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token") + .send({ + github: "https://github.com/zkldi", + }); + + t.equal(res.body.body.socialMedia.github, "zkldi"); + + const dbUser = await db.users.findOne({ id: 1 }); + + t.equal(dbUser?.socialMedia.github, "zkldi"); + + t.end(); + }); + + t.test("Should correctly strip twitch urls.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token") + .send({ + twitch: "https://twitch.tv/zkldi", + }); + + t.equal(res.body.body.socialMedia.twitch, "zkldi"); + + const dbUser = await db.users.findOne({ id: 1 }); + + t.equal(dbUser?.socialMedia.twitch, "zkldi"); + + t.end(); + }); + + t.test("Should correctly strip steam urls.", async (t) => { + const res = await mockApi + .patch("/api/v1/users/1") + .set("Authorization", "Bearer valid_token") + .send({ + steam: "https://steamcommunity.com/id/zkldi", + }); + + t.equal(res.body.body.socialMedia.steam, "zkldi"); + + const dbUser = await db.users.findOne({ id: 1 }); + + t.equal(dbUser?.socialMedia.steam, "zkldi"); + + t.end(); + }); + + t.end(); +}); + t.test("GET /api/v1/users/:userID/game-stats", (t) => { t.beforeEach(ResetDBState); diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index 3396fb733..5833f8bb1 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -1,11 +1,17 @@ import { Router } from "express"; import db from "external/mongo/db"; import { SYMBOL_TachiData } from "lib/constants/tachi"; -import { GetUserFromParam } from "./middleware"; +import { GetUserFromParam, RequireAuthedAsUser } from "./middleware"; import gamePTRouter from "./games/_game/_playtype/router"; import bannerRouter from "./banner/router"; import pfpRouter from "./pfp/router"; import integrationsRouter from "./integrations/router"; +import prValidate from "server/middleware/prudence-validate"; +import p from "prudence"; +import { optNull, optNullFluffStrField } from "utils/prudence"; +import { DeleteUndefinedProps, StripUrl } from "utils/misc"; +import { RequirePermissions } from "server/middleware/auth"; +import { GetUserWithID } from "utils/user"; const router: Router = Router({ mergeParams: true }); @@ -25,6 +31,115 @@ router.get("/", (req, res) => { }); }); +interface UserPatchBody { + about?: string | null; + status?: string | null; + discord?: string | null; + twitter?: string | null; + twitch?: string | null; + youtube?: string | null; + github?: string | null; + steam?: string | null; +} + +/** + * Modify this user document. All parameters are optional. + * + * @param about - An about me, this is rendered as markdown. + * @param status - A user status. This is not rendered as markdown, and is short. + * @param discord - The user's discord tag. + * @param twitter - The user's twitter tag. + * @param github - The user's github. + * @param steam - The user's steamID. + * @param youtube - The user's youtube. + * @param twitch - The user's twitch. + * + * @name PATCH /api/v1/users/:userID + */ +router.patch( + "/", + RequireAuthedAsUser, + RequirePermissions("customise_profile"), + prValidate({ + about: optNull(p.isBoundedString(3, 2000)), + status: optNullFluffStrField, + discord: optNullFluffStrField, + twitter: optNullFluffStrField, + github: optNullFluffStrField, + steam: optNullFluffStrField, + youtube: optNullFluffStrField, + twitch: optNullFluffStrField, + }), + async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + if (Object.keys(req.body).length === 0) { + return res.status(400).json({ + success: false, + description: `Nothing was provided to modify.`, + }); + } + + const body: UserPatchBody = req.body; + + // Hack stuff for user experience. + // In kt1, users would repeatedly mess up these fields. + if (body.twitter) { + body.twitter = StripUrl("twitter.com/", body.twitter); + } + if (body.github) { + body.github = StripUrl("github.com/", body.github); + } + if (body.youtube) { + // youtube has two user urls lol + body.youtube = StripUrl("youtube.com/user/", body.youtube); + body.youtube = StripUrl("youtube.com/channel/", body.youtube); + } + if (body.twitch) { + body.twitch = StripUrl("twitch.tv/", body.twitch); + } + if (body.steam) { + body.steam = StripUrl("steamcommunity.com/id/", body.steam); + } + + // :( + const modifyObject: any = { + about: body.about, + status: body.status, + }; + + for (const socMed of [ + "twitch", + "github", + "youtube", + "steam", + "twitter", + "discord", + ] as const) { + modifyObject[`socialMedia.${socMed}`] = body[socMed]; + } + + DeleteUndefinedProps(modifyObject); + + await db.users.update( + { + id: user.id, + }, + { + $set: modifyObject, + } + ); + + const newUser = await GetUserWithID(user.id); + + return res.status(200).json({ + success: true, + description: `Successfully updated user.`, + body: newUser, + }); + } +); + /** * Returns all of the game-stats this user has. * This endpoint doubles up as a way of checking what games a user has played. diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index 7922439d3..402e7a3ed 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -75,4 +75,24 @@ export function IsString(val: unknown): val is string { export function DedupeArr(arr: T[]): T[] { return [...new Set(arr)]; -} \ No newline at end of file +} + +export function StripUrl(url: string, userInput: string | null) { + if (!userInput) { + return userInput; + } + + if (userInput.toLowerCase().includes(url)) { + return userInput.split(url)[1]; + } + + return userInput; +} + +export function DeleteUndefinedProps(record: any) { + for (const key in record) { + if (record[key] === undefined) { + delete record[key]; + } + } +} diff --git a/server/src/utils/prudence.ts b/server/src/utils/prudence.ts index 53876c00b..78d215914 100644 --- a/server/src/utils/prudence.ts +++ b/server/src/utils/prudence.ts @@ -11,3 +11,5 @@ export function FormatPrError(err: PrudenceError, foreword = "Error") { } export const optNull = (v: ValidSchemaValue) => p.optional(p.nullable(v)); + +export const optNullFluffStrField = optNull(p.isBoundedString(3, 140));