From 6a6469638e5e74de24c60ee5d7dd099105285c01 Mon Sep 17 00:00:00 2001 From: zkldi Date: Sat, 4 Sep 2021 10:08:31 +0100 Subject: [PATCH] Invite Management and Invite Reading for Admins Fixes #270 --- server/src/lib/constants/time.ts | 2 + server/src/lib/invites/invites.ts | 33 ++++ server/src/server/middleware/type-require.ts | 4 +- .../v1/users/_userID/invites/router.test.ts | 159 ++++++++++++++++++ .../api/v1/users/_userID/invites/router.ts | 113 +++++++++++++ .../router/api/v1/users/_userID/router.ts | 2 + server/src/test-utils/mock-db/invites.json | 2 +- 7 files changed, 312 insertions(+), 3 deletions(-) create mode 100644 server/src/lib/invites/invites.ts create mode 100644 server/src/server/router/api/v1/users/_userID/invites/router.test.ts create mode 100644 server/src/server/router/api/v1/users/_userID/invites/router.ts diff --git a/server/src/lib/constants/time.ts b/server/src/lib/constants/time.ts index e2581e14c..c2148495f 100644 --- a/server/src/lib/constants/time.ts +++ b/server/src/lib/constants/time.ts @@ -3,3 +3,5 @@ export const ONE_MINUTE = ONE_SECOND * 60; export const ONE_HOUR = ONE_MINUTE * 60; export const ONE_DAY = ONE_HOUR * 24; export const ONE_WEEK = ONE_DAY * 7; +export const ONE_MONTH = ONE_WEEK * 4; +export const ONE_YEAR = ONE_MONTH * 12; diff --git a/server/src/lib/invites/invites.ts b/server/src/lib/invites/invites.ts new file mode 100644 index 000000000..f4b06addd --- /dev/null +++ b/server/src/lib/invites/invites.ts @@ -0,0 +1,33 @@ +import { ONE_MONTH } from "lib/constants/time"; +import { PublicUserDocument } from "tachi-common"; + +// These aren't controlled by config because they only apply +// to kamaitachi, and I'm lazy in that regard. +const INVITE_BATCH_SIZE = 2; +const INVITE_CAP = 100; +const BETA_USER_BONUS = 5; + +/** + * Users are only allowed to invite so many users, and their invites are + * trickled out in bursts of INVITE_BATCH_SIZE. + * + * Users get those N additional invites every month since they join. + * This is capped at INVITE_CAP, which defaults to 100. + */ +export function GetTotalAllowedInvites(user: PublicUserDocument) { + const joinedSince = Date.now() - user.joinDate; + + const monthsSinceJoin = Math.floor(joinedSince / ONE_MONTH); + + let invites = monthsSinceJoin * INVITE_BATCH_SIZE; + + if (user.badges.includes("alpha") || user.badges.includes("beta")) { + invites += BETA_USER_BONUS; + } + + if (invites > INVITE_CAP) { + return INVITE_CAP; + } + + return invites; +} diff --git a/server/src/server/middleware/type-require.ts b/server/src/server/middleware/type-require.ts index 721a760cd..3b2552b76 100644 --- a/server/src/server/middleware/type-require.ts +++ b/server/src/server/middleware/type-require.ts @@ -3,7 +3,7 @@ import { RequestHandler } from "express"; /** * Middleware that makes the route only available under Bokutachi. - * Note that if the special type "tachi" is set (which is set for testing purposes) + * Note that if the special type "omni" is set (which is set for testing purposes) * this restriction is bypassed. */ export const RequireBokutachi: RequestHandler = (req, res, next) => { @@ -19,7 +19,7 @@ export const RequireBokutachi: RequestHandler = (req, res, next) => { /** * Middleware that makes the route only available under Kamaitachi. - * Note that if the special type "tachi" is set (which is set for testing purposes) + * Note that if the special type "omni" is set (which is set for testing purposes) * this restriction is bypassed. */ export const RequireKamaitachi: RequestHandler = (req, res, next) => { diff --git a/server/src/server/router/api/v1/users/_userID/invites/router.test.ts b/server/src/server/router/api/v1/users/_userID/invites/router.test.ts new file mode 100644 index 000000000..61820a75a --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/invites/router.test.ts @@ -0,0 +1,159 @@ +import db from "external/mongo/db"; +import { ONE_MONTH } from "lib/constants/time"; +import { InviteCodeDocument, PublicUserDocument } 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"; + +t.test("GET /api/v1/users/:userID/invites", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should return all of this users created invites and who used them.", async (t) => { + await db.users.insert({ + id: 2, + username: "other_dude", + usernameLowercase: "other_dude", + } as PublicUserDocument); + + const res = await mockApi.get("/api/v1/users/1/invites").set("Cookie", cookie); + + t.strictSame( + (res.body.body.invites as InviteCodeDocument[]).sort( + (a, b) => a.createdAt - b.createdAt + ), + [ + { + code: "example_invite", + createdBy: 1, + createdAt: 0, + consumed: false, + consumedBy: null, + consumedAt: null, + }, + { + code: "example_consumed_invite", + createdBy: 1, + createdAt: 1, + consumed: true, + consumedBy: 2, + consumedAt: 123, + }, + ] + ); + + t.strictSame(res.body.body.consumers, [ + { + id: 2, + username: "other_dude", + usernameLowercase: "other_dude", + }, + ]); + + t.end(); + }); + + t.test("Should require self-key level authentication.", async (t) => { + const res = await mockApi.get("/api/v1/users/1/invites"); + + t.equal(res.statusCode, 401); + + const res2 = await mockApi + .get("/api/v1/users/1/invites") + .set("Authorization", "Bearer fake_api_token"); + + t.equal(res2.statusCode, 403); + + t.end(); + }); + + t.end(); +}); + +t.test("GET /api/v1/users/:userID/invites/limit", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should return this users current limit on invites.", async (t) => { + // because this thing is time-based, we need to make sure + // this is always relative to right now. + + await db.users.update({ id: 1 }, { $set: { joinDate: Date.now() - ONE_MONTH * 2.5 } }); + const res = await mockApi.get("/api/v1/users/1/invites/limit").set("Cookie", cookie); + + t.equal(res.body.body.invites, 2); + t.equal(res.body.body.limit, 4); + + t.end(); + }); + + t.test("Should require self-key level authentication.", async (t) => { + const res = await mockApi.get("/api/v1/users/1/invites/limit"); + + t.equal(res.statusCode, 401); + + const res2 = await mockApi + .get("/api/v1/users/1/invites/limit") + .set("Authorization", "Bearer fake_api_token"); + + t.equal(res2.statusCode, 403); + + t.end(); + }); + + t.end(); +}); + +t.test("POST /api/v1/users/:userID/invites/create", async (t) => { + t.beforeEach(ResetDBState); + + const cookie = await CreateFakeAuthCookie(mockApi); + + t.test("Should create a new invite.", async (t) => { + await db.users.update({ id: 1 }, { $set: { joinDate: Date.now() - ONE_MONTH * 2.5 } }); + + const res = await mockApi.post("/api/v1/users/1/invites/create").set("Cookie", cookie); + + t.equal(res.statusCode, 200); + + const dbRes = await db.invites.findOne({ + code: res.body.body.code, + }); + + t.not(dbRes, null, "Should exist in the database."); + + t.end(); + }); + + t.test("Should honor invite limit.", async (t) => { + await db.invites.remove({}); + // users with less than a month of life in them dont get invites, + // so this will force an invite limit honor. + await db.users.update({ id: 1 }, { $set: { joinDate: Date.now() } }); + + const res = await mockApi.post("/api/v1/users/1/invites/create").set("Cookie", cookie); + + t.equal(res.statusCode, 400); + + t.end(); + }); + + t.test("Should require self-key level authentication.", async (t) => { + const res = await mockApi.post("/api/v1/users/1/invites/create"); + + t.equal(res.statusCode, 401); + + const res2 = await mockApi + .post("/api/v1/users/1/invites/create") + .set("Authorization", "Bearer fake_api_token"); + + t.equal(res2.statusCode, 403); + + t.end(); + }); + + t.end(); +}); diff --git a/server/src/server/router/api/v1/users/_userID/invites/router.ts b/server/src/server/router/api/v1/users/_userID/invites/router.ts new file mode 100644 index 000000000..799a989c9 --- /dev/null +++ b/server/src/server/router/api/v1/users/_userID/invites/router.ts @@ -0,0 +1,113 @@ +import { Router } from "express"; +import db from "external/mongo/db"; +import { SYMBOL_TachiData } from "lib/constants/tachi"; +import { GetTotalAllowedInvites } from "lib/invites/invites"; +import { RequireKamaitachi } from "server/middleware/type-require"; +import { InviteCodeDocument } from "tachi-common"; +import { Random20Hex } from "utils/misc"; +import { GetUsersWithIDs } from "utils/user"; +import { RequireSelfRequestFromUser } from "../middleware"; + +const router: Router = Router({ mergeParams: true }); + +router.use(RequireKamaitachi); +router.use(RequireSelfRequestFromUser); + +/** + * Retrieve all of this users created invites. + * + * @name GET /api/v1/users/:userID/invites + */ +router.get("/", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + const invites = await db.invites.find({ + createdBy: user.id, + }); + + const consumers = await GetUsersWithIDs( + invites.map((e) => e.consumedBy).filter((e) => e !== null) as number[] + ); + + return res.status(200).json({ + success: true, + description: `Found ${invites.length} invites.`, + body: { invites, consumers }, + }); +}); + +/** + * Return how many invites this user can create, and how many they + * have already created. + * + * @name GET /api/v1/users/:userID/invites/limit + */ +router.get("/limit", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + const invites = await db.invites.count({ createdBy: user.id }); + const limit = GetTotalAllowedInvites(user); + + return res.status(200).json({ + success: true, + description: `Calculated invite limit.`, + body: { + invites, + limit, + }, + }); +}); + +const InviteLocks = new Set(); + +/** + * Create a new invite. + * + * @name POST /api/v1/users/:userID/invites/create + */ +router.post("/create", async (req, res) => { + const user = req[SYMBOL_TachiData]!.requestedUser!; + + // race condition protection + // to avoid users double-creating invites. + if (InviteLocks.has(user.id)) { + return res.status(409).json({ + success: false, + description: `You already have an outgoing invite creation request.`, + }); + } + + InviteLocks.add(user.id); + + const existingInvites = await db.invites.count({ createdBy: user.id }); + + if (existingInvites >= GetTotalAllowedInvites(user)) { + InviteLocks.delete(user.id); + + return res.status(400).json({ + success: false, + description: `You already have your maximum amount of outgoing invites.`, + }); + } + + const inviteDoc: InviteCodeDocument = { + code: Random20Hex(), + consumed: false, + consumedAt: null, + consumedBy: null, + createdAt: Date.now(), + createdBy: user.id, + }; + + await db.invites.insert(inviteDoc); + + InviteLocks.delete(user.id); + + return res.status(200).json({ + success: true, + description: `Created Invite.`, + body: inviteDoc, + }); +}); + +export default router; 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 dcb8737be..317e63150 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -15,6 +15,7 @@ import { FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; import { UserGameStats } from "tachi-common"; import CreateLogCtx from "lib/logger/logger"; import apiTokensRouter from "./api-tokens/router"; +import invitesRouter from "./invites/router"; const logger = CreateLogCtx(__filename); @@ -208,5 +209,6 @@ router.use("/banner", bannerRouter); router.use("/integrations", integrationsRouter); router.use("/settings", settingsRouter); router.use("/api-tokens", apiTokensRouter); +router.use("/invites", invitesRouter); export default router; diff --git a/server/src/test-utils/mock-db/invites.json b/server/src/test-utils/mock-db/invites.json index 92fdd81da..cff760cf0 100644 --- a/server/src/test-utils/mock-db/invites.json +++ b/server/src/test-utils/mock-db/invites.json @@ -10,7 +10,7 @@ { "code": "example_consumed_invite", "createdBy": 1, - "createdAt": 0, + "createdAt": 1, "consumed": true, "consumedBy": 2, "consumedAt": 123