Add PATCH /users/:userID endpoint

This commit is contained in:
zkldi
2021-08-22 06:07:43 +01:00
parent 48a98b2ae1
commit 466b2dd64e
9 changed files with 363 additions and 8 deletions
+1 -1
View File
@@ -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"
},
+4 -4
View File
@@ -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
@@ -90,6 +90,7 @@ export async function AddNewUser(
email: email,
clan: null,
socialMedia: {},
status: null,
customBanner: false,
customPfp: false,
joinDate: Date.now(),
@@ -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.
*
@@ -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,
@@ -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);
@@ -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.
+21 -1
View File
@@ -75,4 +75,24 @@ export function IsString(val: unknown): val is string {
export function DedupeArr<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
}
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];
}
}
}
+2
View File
@@ -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));