Merge pull request #329 from zkldi:zkldi/issue-270-Invite-Management-and-Invite-Reading-for-Admins

Invite Management and Invite Reading for Admins
This commit is contained in:
zkldi
2021-09-04 10:12:59 +01:00
committed by GitHub
7 changed files with 312 additions and 3 deletions
+2
View File
@@ -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;
+33
View File
@@ -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;
}
+2 -2
View File
@@ -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) => {
@@ -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();
});
@@ -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;
@@ -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;
+1 -1
View File
@@ -10,7 +10,7 @@
{
"code": "example_consumed_invite",
"createdBy": 1,
"createdAt": 0,
"createdAt": 1,
"consumed": true,
"consumedBy": 2,
"consumedAt": 123