mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-04 21:08:09 +03:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{
|
||||
"code": "example_consumed_invite",
|
||||
"createdBy": 1,
|
||||
"createdAt": 0,
|
||||
"createdAt": 1,
|
||||
"consumed": true,
|
||||
"consumedBy": 2,
|
||||
"consumedAt": 123
|
||||
|
||||
Reference in New Issue
Block a user