impr: use tsrest for admin endpoint (@fehmer) (#5713)

!nuf
This commit is contained in:
Christian Fehmer 2024-08-08 12:41:07 +02:00 committed by GitHub
parent 9f9663682d
commit 460f803bca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 775 additions and 248 deletions

View file

@ -0,0 +1,429 @@
import request, { Test as SuperTest } from "supertest";
import app from "../../../src/app";
import { ObjectId } from "mongodb";
import * as Configuration from "../../../src/init/configuration";
import * as AdminUuidDal from "../../../src/dal/admin-uids";
import * as UserDal from "../../../src/dal/user";
import * as ReportDal from "../../../src/dal/report";
import GeorgeQueue from "../../../src/queues/george-queue";
import * as AuthUtil from "../../../src/utils/auth";
import _ from "lodash";
const mockApp = request(app);
const configuration = Configuration.getCachedConfiguration();
const uid = new ObjectId().toHexString();
describe("ApeKeyController", () => {
const isAdminMock = vi.spyOn(AdminUuidDal, "isAdmin");
beforeEach(async () => {
isAdminMock.mockReset();
await enableAdminEndpoints(true);
isAdminMock.mockResolvedValue(true);
});
describe("check for admin", () => {
it("should succeed if user is admin", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.get("/admin")
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body).toEqual({
message: "OK",
data: null,
});
expect(isAdminMock).toHaveBeenCalledWith(uid);
});
it("should fail if user is no admin", async () => {
await expectFailForNonAdmin(
mockApp.get("/admin").set("authorization", `Uid ${uid}`)
);
});
it("should fail if admin endpoints are disabled", async () => {
await expectFailForDisabledEndpoint(
mockApp.get("/admin").set("authorization", `Uid ${uid}`)
);
});
});
describe("toggle ban", () => {
const userBannedMock = vi.spyOn(UserDal, "setBanned");
const georgeBannedMock = vi.spyOn(GeorgeQueue, "userBanned");
const getUserMock = vi.spyOn(UserDal, "getPartialUser");
beforeEach(() => {
[userBannedMock, georgeBannedMock, getUserMock].forEach((it) =>
it.mockReset()
);
});
it("should ban user with discordId", async () => {
//GIVEN
const victimUid = new ObjectId().toHexString();
getUserMock.mockResolvedValue({
banned: false,
discordId: "discordId",
} as any);
//WHEN
const { body } = await mockApp
.post("/admin/toggleBan")
.send({ uid: victimUid })
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body).toEqual({
message: "Ban toggled",
data: { banned: true },
});
expect(getUserMock).toHaveBeenCalledWith(victimUid, "toggle ban", [
"banned",
"discordId",
]);
expect(userBannedMock).toHaveBeenCalledWith(victimUid, true);
expect(georgeBannedMock).toHaveBeenCalledWith("discordId", true);
});
it("should unban user without discordId", async () => {
//GIVEN
const victimUid = new ObjectId().toHexString();
getUserMock.mockResolvedValue({
banned: true,
} as any);
//WHEN
const { body } = await mockApp
.post("/admin/toggleBan")
.send({ uid: victimUid })
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body).toEqual({
message: "Ban toggled",
data: { banned: false },
});
expect(getUserMock).toHaveBeenCalledWith(victimUid, "toggle ban", [
"banned",
"discordId",
]);
expect(userBannedMock).toHaveBeenCalledWith(victimUid, false);
expect(georgeBannedMock).not.toHaveBeenCalled();
});
it("should fail without mandatory properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.post("/admin/toggleBan")
.send({})
.set("authorization", `Uid ${uid}`)
.expect(422);
//THEN
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: ['"uid" Required'],
});
});
it("should fail with unknown properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.post("/admin/toggleBan")
.send({ uid: new ObjectId().toHexString(), extra: "value" })
.set("authorization", `Uid ${uid}`)
.expect(422);
//THEN
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
it("should fail if user is no admin", async () => {
await expectFailForNonAdmin(
mockApp
.post("/admin/toggleBan")
.send({ uid: new ObjectId().toHexString() })
.set("authorization", `Uid ${uid}`)
);
});
it("should fail if admin endpoints are disabled", async () => {
//GIVEN
await expectFailForDisabledEndpoint(
mockApp
.post("/admin/toggleBan")
.send({ uid: new ObjectId().toHexString() })
.set("authorization", `Uid ${uid}`)
);
});
});
describe("accept reports", () => {
const getReportsMock = vi.spyOn(ReportDal, "getReports");
const deleteReportsMock = vi.spyOn(ReportDal, "deleteReports");
const addToInboxMock = vi.spyOn(UserDal, "addToInbox");
beforeEach(() => {
[getReportsMock, deleteReportsMock, addToInboxMock].forEach((it) =>
it.mockReset()
);
});
it("should accept reports", async () => {
//GIVEN
const reportOne = {
id: "1",
reason: "one",
} as any as MonkeyTypes.Report;
const reportTwo = {
id: "2",
reason: "two",
} as any as MonkeyTypes.Report;
getReportsMock.mockResolvedValue([reportOne, reportTwo]);
//WHEN
const { body } = await mockApp
.post("/admin/report/accept")
.send({
reports: [{ reportId: reportOne.id }, { reportId: reportTwo.id }],
})
.set("authorization", `Uid ${uid}`)
.expect(200);
expect(body).toEqual({
message: "Reports removed and users notified.",
data: null,
});
expect(addToInboxMock).toBeCalledTimes(2);
expect(deleteReportsMock).toHaveBeenCalledWith(["1", "2"]);
});
it("should fail wihtout mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.post("/admin/report/accept")
.send({})
.set("authorization", `Uid ${uid}`)
.expect(422);
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: ['"reports" Required'],
});
});
it("should fail with empty reports", async () => {
//WHEN
const { body } = await mockApp
.post("/admin/report/accept")
.send({ reports: [] })
.set("authorization", `Uid ${uid}`)
.expect(422);
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: [
'"reports" Array must contain at least 1 element(s)',
],
});
});
it("should fail with unknown properties", async () => {
//WHEN
const { body } = await mockApp
.post("/admin/report/accept")
.send({ reports: [{ reportId: "1", extra2: "value" }], extra: "value" })
.set("authorization", `Uid ${uid}`)
.expect(422);
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: [
`"reports.0" Unrecognized key(s) in object: 'extra2'`,
"Unrecognized key(s) in object: 'extra'",
],
});
});
it("should fail if user is no admin", async () => {
await expectFailForNonAdmin(
mockApp
.post("/admin/report/accept")
.send({ reports: [] })
.set("authorization", `Uid ${uid}`)
);
});
it("should fail if admin endpoints are disabled", async () => {
//GIVEN
await expectFailForDisabledEndpoint(
mockApp
.post("/admin/report/accept")
.send({ reports: [] })
.set("authorization", `Uid ${uid}`)
);
});
});
describe("reject reports", () => {
const getReportsMock = vi.spyOn(ReportDal, "getReports");
const deleteReportsMock = vi.spyOn(ReportDal, "deleteReports");
const addToInboxMock = vi.spyOn(UserDal, "addToInbox");
beforeEach(() => {
[getReportsMock, deleteReportsMock, addToInboxMock].forEach((it) =>
it.mockReset()
);
});
it("should reject reports", async () => {
//GIVEN
const reportOne = {
id: "1",
reason: "one",
} as any as MonkeyTypes.Report;
const reportTwo = {
id: "2",
reason: "two",
} as any as MonkeyTypes.Report;
getReportsMock.mockResolvedValue([reportOne, reportTwo]);
//WHEN
const { body } = await mockApp
.post("/admin/report/reject")
.send({
reports: [
{ reportId: reportOne.id, reason: "test" },
{ reportId: reportTwo.id },
],
})
.set("authorization", `Uid ${uid}`)
.expect(200);
expect(body).toEqual({
message: "Reports removed and users notified.",
data: null,
});
expect(addToInboxMock).toHaveBeenCalledTimes(2);
expect(deleteReportsMock).toHaveBeenCalledWith(["1", "2"]);
});
it("should fail wihtout mandatory properties", async () => {
//WHEN
const { body } = await mockApp
.post("/admin/report/reject")
.send({})
.set("authorization", `Uid ${uid}`)
.expect(422);
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: ['"reports" Required'],
});
});
it("should fail with empty reports", async () => {
//WHEN
const { body } = await mockApp
.post("/admin/report/reject")
.send({ reports: [] })
.set("authorization", `Uid ${uid}`)
.expect(422);
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: [
'"reports" Array must contain at least 1 element(s)',
],
});
});
it("should fail with unknown properties", async () => {
//WHEN
const { body } = await mockApp
.post("/admin/report/reject")
.send({ reports: [{ reportId: "1", extra2: "value" }], extra: "value" })
.set("authorization", `Uid ${uid}`)
.expect(422);
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: [
`"reports.0" Unrecognized key(s) in object: 'extra2'`,
"Unrecognized key(s) in object: 'extra'",
],
});
});
it("should fail if user is no admin", async () => {
await expectFailForNonAdmin(
mockApp
.post("/admin/report/reject")
.send({ reports: [] })
.set("authorization", `Uid ${uid}`)
);
});
it("should fail if admin endpoints are disabled", async () => {
//GIVEN
await expectFailForDisabledEndpoint(
mockApp
.post("/admin/report/reject")
.send({ reports: [] })
.set("authorization", `Uid ${uid}`)
);
});
});
describe("send forgot password email", () => {
const sendForgotPasswordEmailMock = vi.spyOn(
AuthUtil,
"sendForgotPasswordEmail"
);
beforeEach(() => {
sendForgotPasswordEmailMock.mockReset();
});
it("should send forgot password link", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.post("/admin/sendForgotPasswordEmail")
.send({ email: "meowdec@example.com" })
.set("authorization", `Uid ${uid}`)
.expect(200);
//THEN
expect(body).toEqual({
message: "Password reset request email sent.",
data: null,
});
expect(sendForgotPasswordEmailMock).toHaveBeenCalledWith(
"meowdec@example.com"
);
});
});
async function expectFailForNonAdmin(call: SuperTest): Promise<void> {
isAdminMock.mockResolvedValue(false);
const { body } = await call.expect(403);
expect(body.message).toEqual("You don't have permission to do this.");
}
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
await enableAdminEndpoints(false);
const { body } = await call.expect(503);
expect(body.message).toEqual("Admin endpoints are currently disabled.");
}
});
async function enableAdminEndpoints(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
admin: { endpointsEnabled: enabled },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}

View file

@ -1,4 +1,4 @@
import request from "supertest";
import request, { Test as SuperTest } from "supertest";
import app from "../../../src/app";
import * as ApeKeyDal from "../../../src/dal/ape-keys";
import { ObjectId } from "mongodb";
@ -65,31 +65,13 @@ describe("ApeKeyController", () => {
expect(getApeKeysMock).toHaveBeenCalledWith(uid);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.get("/ape-keys")
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
await expectFailForDisabledEndpoint(
mockApp.get("/ape-keys").set("authorization", `Uid ${uid}`)
);
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.get("/ape-keys")
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
await expectFailForNoPermissions(
mockApp.get("/ape-keys").set("authorization", `Uid ${uid}`)
);
});
});
@ -190,33 +172,19 @@ describe("ApeKeyController", () => {
);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
await expectFailForDisabledEndpoint(
mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
);
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
await expectFailForNoPermissions(
mockApp
.post("/ape-keys")
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
);
});
});
@ -290,33 +258,19 @@ describe("ApeKeyController", () => {
});
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
await expectFailForDisabledEndpoint(
mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
);
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
await expectFailForNoPermissions(
mockApp
.patch(`/ape-keys/${apeKeyId}`)
.send({ name: "test", enabled: false })
.set("authorization", `Uid ${uid}`)
);
});
});
@ -352,35 +306,33 @@ describe("ApeKeyController", () => {
.expect(404);
});
it("should fail if apeKeys endpoints are disabled", async () => {
//GIVEN
await enableApeKeysEndpoints(false);
//WHEN
const { body } = await mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
.expect(503);
//THEN
expect(body.message).toEqual("ApeKeys are currently disabled.");
await expectFailForDisabledEndpoint(
mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
);
});
it("should fail if user has no apeKey permissions", async () => {
//GIVEN
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
//WHEN
const { body } = await mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
.expect(403);
//THEN
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
await expectFailForNoPermissions(
mockApp
.delete(`/ape-keys/${apeKeyId}`)
.set("authorization", `Uid ${uid}`)
);
});
});
async function expectFailForNoPermissions(call: SuperTest): Promise<void> {
getUserMock.mockResolvedValue(user(uid, { canManageApeKeys: false }));
const { body } = await call.expect(403);
expect(body.message).toEqual(
"You have lost access to ape keys, please contact support"
);
}
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
await enableApeKeysEndpoints(false);
const { body } = await call.expect(503);
expect(body.message).toEqual("ApeKeys are currently disabled.");
}
});
function apeKeyDb(

View file

@ -0,0 +1,29 @@
import { ObjectId } from "mongodb";
import * as AdminUidsDal from "../../src/dal/admin-uids";
describe("AdminUidsDal", () => {
describe("isAdmin", () => {
it("should return true for existing admin user", async () => {
//GIVEN
const uid = new ObjectId().toHexString();
AdminUidsDal.getCollection().insertOne({
_id: new ObjectId(),
uid: uid,
});
//WHEN / THEN
expect(await AdminUidsDal.isAdmin(uid)).toBe(true);
});
it("should return false for non-existing admin user", async () => {
//GIVEN
AdminUidsDal.getCollection().insertOne({
_id: new ObjectId(),
uid: "admin",
});
//WHEN / THEN
expect(await AdminUidsDal.isAdmin("regularUser")).toBe(false);
});
});
});

View file

@ -66,6 +66,12 @@ export function getOpenApi(): OpenAPIObject {
description: "Ape keys provide access to certain API endpoints.",
"x-displayName": "Ape Keys",
},
{
name: "admin",
description:
"Various administrative endpoints. Require user to have admin permissions.",
"x-displayName": "Admin",
},
],
},

View file

@ -1,31 +1,75 @@
import { MonkeyResponse } from "../../utils/monkey-response";
import { MonkeyResponse2 } from "../../utils/monkey-response";
import { buildMonkeyMail } from "../../utils/monkey-mail";
import * as UserDAL from "../../dal/user";
import * as ReportDAL from "../../dal/report";
import GeorgeQueue from "../../queues/george-queue";
import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth";
import {
AcceptReportsRequest,
RejectReportsRequest,
SendForgotPasswordEmailRequest,
ToggleBanRequest,
ToggleBanResponse,
} from "@monkeytype/contracts/admin";
import MonkeyError from "../../utils/error";
import { Configuration } from "@monkeytype/shared-types";
import { addImportantLog } from "../../dal/logs";
export async function test(): Promise<MonkeyResponse> {
return new MonkeyResponse("OK");
export async function test(
_req: MonkeyTypes.Request2
): Promise<MonkeyResponse2> {
return new MonkeyResponse2("OK", null);
}
export async function toggleBan(
req: MonkeyTypes.Request2<undefined, ToggleBanRequest>
): Promise<ToggleBanResponse> {
const { uid } = req.body;
const user = await UserDAL.getPartialUser(uid, "toggle ban", [
"banned",
"discordId",
]);
const discordId = user.discordId;
const discordIdIsValid = discordId !== undefined && discordId !== "";
await UserDAL.setBanned(uid, !user.banned);
if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, !user.banned);
void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid);
return new MonkeyResponse2(`Ban toggled`, {
banned: !user.banned,
});
}
export async function acceptReports(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
return handleReports(req, true);
req: MonkeyTypes.Request2<undefined, AcceptReportsRequest>
): Promise<MonkeyResponse2> {
await handleReports(
req.body.reports.map((it) => ({ ...it })),
true,
req.ctx.configuration.users.inbox
);
return new MonkeyResponse2("Reports removed and users notified.", null);
}
export async function rejectReports(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
return handleReports(req, false);
req: MonkeyTypes.Request2<undefined, RejectReportsRequest>
): Promise<MonkeyResponse2> {
await handleReports(
req.body.reports.map((it) => ({ ...it })),
false,
req.ctx.configuration.users.inbox
);
return new MonkeyResponse2("Reports removed and users notified.", null);
}
export async function handleReports(
req: MonkeyTypes.Request,
accept: boolean
): Promise<MonkeyResponse> {
const { reports } = req.body;
// TODO: remove once this gets converted to ts-rest
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
reports: { reportId: string; reason?: string }[],
accept: boolean,
inboxConfig: Configuration["users"]["inbox"]
): Promise<void> {
const reportIds = reports.map(({ reportId }) => reportId);
const reportsFromDb = await ReportDAL.getReports(reportIds);
@ -37,10 +81,9 @@ export async function handleReports(
);
if (missingReportIds.length > 0) {
return new MonkeyResponse(
`Reports not found for some IDs`,
missingReportIds,
404
throw new MonkeyError(
404,
`Reports not found for some IDs ${missingReportIds.join(",")}`
);
}
@ -50,11 +93,7 @@ export async function handleReports(
try {
const report = reportById.get(reportId);
if (!report) {
return new MonkeyResponse(
`Report not found for ID: ${reportId}`,
null,
404
);
throw new MonkeyError(404, `Report not found for ID: ${reportId}`);
}
let mailBody = "";
@ -75,14 +114,17 @@ export async function handleReports(
subject: mailSubject,
body: mailBody,
});
await UserDAL.addToInbox(
report.uid,
[mail],
req.ctx.configuration.users.inbox
);
await UserDAL.addToInbox(report.uid, [mail], inboxConfig);
} catch (e) {
return new MonkeyResponse(e.message, null, e.status);
throw new MonkeyError(e.status, e.message);
}
}
return new MonkeyResponse("Reports removed and users notified.");
}
export async function sendForgotPasswordEmail(
req: MonkeyTypes.Request2<undefined, SendForgotPasswordEmailRequest>
): Promise<MonkeyResponse2> {
const { email } = req.body;
await authSendForgotPasswordEmail(email);
return new MonkeyResponse2("Password reset request email sent.", null);
}

View file

@ -37,6 +37,7 @@ import {
UserProfileDetails,
} from "@monkeytype/shared-types";
import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs";
import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth";
async function verifyCaptcha(captcha: string): Promise<void> {
if (!(await verify(captcha))) {
@ -159,29 +160,7 @@ export async function sendForgotPasswordEmail(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
const { email } = req.body;
try {
const uid = (await FirebaseAdmin().auth().getUserByEmail(email)).uid;
const userInfo = await UserDAL.getPartialUser(
uid,
"request forgot password email",
["name"]
);
const link = await FirebaseAdmin()
.auth()
.generatePasswordResetLink(email, {
url: isDevEnvironment()
? "http://localhost:3000"
: "https://monkeytype.com",
});
await emailQueue.sendForgotPasswordEmail(email, userInfo.name, link);
} catch {
return new MonkeyResponse(
"Password reset request received. If the email is valid, you will receive an email shortly."
);
}
await authSendForgotPasswordEmail(email);
return new MonkeyResponse(
"Password reset request received. If the email is valid, you will receive an email shortly."
);

View file

@ -1,107 +1,46 @@
// import joi from "joi";
import { Router } from "express";
import { authenticateRequest } from "../../middlewares/auth";
import * as AdminController from "../controllers/admin";
import { adminLimit } from "../../middlewares/rate-limit";
import { sendForgotPasswordEmail, toggleBan } from "../controllers/user";
import joi from "joi";
import * as AdminController from "../controllers/admin";
import { adminContract } from "@monkeytype/contracts/admin";
import { initServer } from "@ts-rest/express";
import { validate } from "../../middlewares/configuration";
import { checkIfUserIsAdmin } from "../../middlewares/permission";
import { asyncHandler } from "../../middlewares/utility";
import { validateRequest } from "../../middlewares/validation";
import { callController } from "../ts-rest-adapter";
const router = Router();
const commonMiddleware = [
adminLimit,
router.use(
validate({
criteria: (configuration) => {
return configuration.admin.endpointsEnabled;
},
invalidMessage: "Admin endpoints are currently disabled.",
})
);
router.get(
"/",
adminLimit,
authenticateRequest({
noCache: true,
}),
checkIfUserIsAdmin(),
asyncHandler(AdminController.test)
);
];
router.post(
"/toggleBan",
adminLimit,
authenticateRequest({
noCache: true,
}),
checkIfUserIsAdmin(),
validateRequest({
body: {
uid: joi.string().required().token(),
},
}),
asyncHandler(toggleBan)
);
router.post(
"/report/accept",
authenticateRequest({
noCache: true,
}),
checkIfUserIsAdmin(),
validateRequest({
body: {
reports: joi
.array()
.items(
joi.object({
reportId: joi.string().required(),
})
)
.required(),
},
}),
asyncHandler(AdminController.acceptReports)
);
router.post(
"/report/reject",
authenticateRequest({
noCache: true,
}),
checkIfUserIsAdmin(),
validateRequest({
body: {
reports: joi
.array()
.items(
joi.object({
reportId: joi.string().required(),
reason: joi.string().optional(),
})
)
.required(),
},
}),
asyncHandler(AdminController.rejectReports)
);
router.post(
"/sendForgotPasswordEmail",
adminLimit,
authenticateRequest({
noCache: true,
}),
checkIfUserIsAdmin(),
validateRequest({
body: {
email: joi.string().email().required(),
},
}),
asyncHandler(sendForgotPasswordEmail)
);
export default router;
const s = initServer();
export default s.router(adminContract, {
test: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.test)(r),
},
toggleBan: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.toggleBan)(r),
},
acceptReports: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.acceptReports)(r),
},
rejectReports: {
middleware: commonMiddleware,
handler: async (r) => callController(AdminController.rejectReports)(r),
},
sendForgotPasswordEmail: {
middleware: commonMiddleware,
handler: async (r) =>
callController(AdminController.sendForgotPasswordEmail)(r),
},
});

View file

@ -47,13 +47,13 @@ const API_ROUTE_MAP = {
"/public": publicStats,
"/leaderboards": leaderboards,
"/quotes": quotes,
"/admin": admin,
"/webhooks": webhooks,
"/docs": docs,
};
const s = initServer();
const router = s.router(contract, {
admin,
apeKeys,
configs,
presets,

View file

@ -1,7 +1,11 @@
import { Collection, WithId } from "mongodb";
import * as db from "../init/db";
export const getCollection = (): Collection<WithId<{ uid: string }>> =>
db.collection("admin-uids");
export async function isAdmin(uid: string): Promise<boolean> {
const doc = await db.collection("admin-uids").findOne({ uid });
const doc = await getCollection().findOne({ uid });
if (doc) {
return true;
} else {

View file

@ -6,6 +6,9 @@ import {
setTokenCacheSize,
} from "./prometheus";
import { type DecodedIdToken, UserRecord } from "firebase-admin/auth";
import { isDevEnvironment } from "./misc";
import emailQueue from "../queues/email-queue";
import * as UserDAL from "../dal/user";
const tokenCache = new LRUCache<string, DecodedIdToken>({
max: 20000,
@ -82,3 +85,28 @@ export async function revokeTokensByUid(uid: string): Promise<void> {
}
}
}
export async function sendForgotPasswordEmail(email: string): Promise<void> {
try {
const uid = (await FirebaseAdmin().auth().getUserByEmail(email)).uid;
const { name } = await UserDAL.getPartialUser(
uid,
"request forgot password email",
["name"]
);
const link = await FirebaseAdmin()
.auth()
.generatePasswordResetLink(email, {
url: isDevEnvironment()
? "http://localhost:3000"
: "https://monkeytype.com",
});
await emailQueue.sendForgotPasswordEmail(email, name, link);
} catch (err) {
if (err.errorInfo?.code !== "auth/user-not-found") {
throw err;
}
}
}

View file

@ -0,0 +1,117 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
EndpointMetadata,
MonkeyResponseSchema,
responseWithData,
} from "./schemas/api";
import { IdSchema } from "./schemas/util";
export const ToggleBanRequestSchema = z
.object({
uid: IdSchema,
})
.strict();
export type ToggleBanRequest = z.infer<typeof ToggleBanRequestSchema>;
export const ToggleBanResponseSchema = responseWithData(
z.object({
banned: z.boolean(),
})
).strict();
export type ToggleBanResponse = z.infer<typeof ToggleBanResponseSchema>;
export const AcceptReportsRequestSchema = z
.object({
reports: z.array(z.object({ reportId: z.string() }).strict()).nonempty(),
})
.strict();
export type AcceptReportsRequest = z.infer<typeof AcceptReportsRequestSchema>;
export const RejectReportsRequestSchema = z
.object({
reports: z
.array(
z
.object({ reportId: z.string(), reason: z.string().optional() })
.strict()
)
.nonempty(),
})
.strict();
export type RejectReportsRequest = z.infer<typeof RejectReportsRequestSchema>;
export const SendForgotPasswordEmailRequestSchema = z
.object({
email: z.string().email(),
})
.strict();
export type SendForgotPasswordEmailRequest = z.infer<
typeof SendForgotPasswordEmailRequestSchema
>;
const c = initContract();
export const adminContract = c.router(
{
test: {
summary: "test permission",
description: "Check for admin permission for the current user",
method: "GET",
path: "/",
responses: {
200: MonkeyResponseSchema,
},
},
toggleBan: {
summary: "toggle user ban",
description: "Ban an unbanned user or unban a banned user.",
method: "POST",
path: "/toggleBan",
body: ToggleBanRequestSchema,
responses: {
200: ToggleBanResponseSchema,
},
},
acceptReports: {
summary: "accept reports",
description: "Accept one or many reports",
method: "POST",
path: "/report/accept",
body: AcceptReportsRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
rejectReports: {
summary: "reject reports",
description: "Reject one or many reports",
method: "POST",
path: "/report/reject",
body: RejectReportsRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
sendForgotPasswordEmail: {
summary: "send forgot password email",
description: "Send a forgot password email to the given user email",
method: "POST",
path: "/sendForgotPasswordEmail",
body: SendForgotPasswordEmailRequestSchema,
responses: {
200: MonkeyResponseSchema,
},
},
},
{
pathPrefix: "/admin",
strictStatusCodes: true,
metadata: {
openApiTags: "admin",
authenticationOptions: { noCache: true },
} as EndpointMetadata,
commonResponses: CommonResponses,
}
);

View file

@ -1,11 +1,13 @@
import { initContract } from "@ts-rest/core";
import { adminContract } from "./admin";
import { apeKeysContract } from "./ape-keys";
import { configsContract } from "./configs";
import { presetsContract } from "./presets";
import { apeKeysContract } from "./ape-keys";
const c = initContract();
export const contract = c.router({
admin: adminContract,
apeKeys: apeKeysContract,
configs: configsContract,
presets: presetsContract,

View file

@ -1,6 +1,6 @@
import { z, ZodSchema } from "zod";
export type OpenApiTag = "configs" | "presets" | "ape-keys";
export type OpenApiTag = "configs" | "presets" | "ape-keys" | "admin";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */