generated from nhcarrigan/template
feat: add ability to log and display sanctions
This commit is contained in:
@@ -16,4 +16,15 @@ model Announcements {
|
||||
content String
|
||||
type String
|
||||
createdAt DateTime @default(now()) @unique
|
||||
}
|
||||
|
||||
model Sanctions {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
number Int @unique
|
||||
platform String
|
||||
uuid String
|
||||
username String
|
||||
type String
|
||||
reason String
|
||||
createdAt DateTime @default(now()) @unique
|
||||
}
|
||||
+2
-1
@@ -13,4 +13,5 @@ TWITTER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_access_token"
|
||||
TWITTER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_access_secret"
|
||||
TWITTER_CONSUMER_KEY="op://Environment Variables - Naomi/Hikari/twitter_consumer_key"
|
||||
TWITTER_CONSUMER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_consumer_secret"
|
||||
TWITTER_BEARER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_bearer_token"
|
||||
TWITTER_BEARER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_bearer_token"
|
||||
SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook"
|
||||
@@ -13,4 +13,5 @@ export const routesWithoutCors = [
|
||||
"/announcement",
|
||||
"/health",
|
||||
"/mcp",
|
||||
"/sanction",
|
||||
];
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ipHook } from "./hooks/ips.js";
|
||||
import { announcementRoutes } from "./routes/announcement.js";
|
||||
import { baseRoutes } from "./routes/base.js";
|
||||
import { mcpRoutes } from "./routes/mcp.js";
|
||||
import { sanctionRoutes } from "./routes/sanction.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
|
||||
const server = fastify({
|
||||
@@ -34,6 +35,7 @@ server.addHook("preHandler", ipHook);
|
||||
server.register(baseRoutes);
|
||||
server.register(announcementRoutes);
|
||||
server.register(mcpRoutes);
|
||||
server.register(sanctionRoutes);
|
||||
|
||||
server.listen({ port: 20_000 }, (error) => {
|
||||
if (error) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
interface Payload {
|
||||
number: number;
|
||||
platform: string;
|
||||
reason: string;
|
||||
type: string;
|
||||
username: string;
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a sanction payload from the API into Discord ComponentsV2.
|
||||
* @param payload -- The sanction payload from the API.
|
||||
* @returns A component JSON array.
|
||||
*/
|
||||
export const getSanctionComponents
|
||||
= (payload: Payload): Array<Record<string, unknown>> => {
|
||||
const { number, platform, reason, type, username, uuid } = payload;
|
||||
return [
|
||||
{
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Discord API standard.
|
||||
accent_color: 15_418_782,
|
||||
components: [
|
||||
{
|
||||
content: `# Case #${number.toString()}: ${type.toUpperCase()}`,
|
||||
type: 10,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
spacing: 1,
|
||||
type: 14,
|
||||
},
|
||||
{
|
||||
content: `## Reason:\n\n${reason}`,
|
||||
type: 10,
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
spacing: 1,
|
||||
type: 14,
|
||||
},
|
||||
{
|
||||
content: `## Metadata\n\n- Platform: ${platform}\n- UUID: ${uuid}\n- Username: ${username}`,
|
||||
type: 10,
|
||||
},
|
||||
],
|
||||
spoiler: false,
|
||||
type: 17,
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { blockedIps } from "../cache/blockedIps.js";
|
||||
import { database } from "../db/database.js";
|
||||
import { getIpFromRequest } from "../modules/getIpFromRequest.js";
|
||||
import { getSanctionComponents } from "../modules/getSanctionComponents.js";
|
||||
import { isValidString } from "../utils/isValidString.js";
|
||||
import type { FastifyPluginAsync } from "fastify";
|
||||
|
||||
const oneDay = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Mounts the entry routes for the application. These routes
|
||||
* should not require CORS, as they are used by external services
|
||||
* such as our uptime monitor.
|
||||
* @param server - The Fastify server instance.
|
||||
*/
|
||||
export const sanctionRoutes: FastifyPluginAsync = async(server) => {
|
||||
server.get("/sanctions", async(_request, reply) => {
|
||||
const sanctions = await database.getInstance().sanctions.findMany({
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
return await reply.status(200).type("application/json").
|
||||
send(sanctions.map((sanction) => {
|
||||
return {
|
||||
number: sanction.number,
|
||||
platform: sanction.platform,
|
||||
reason: sanction.reason,
|
||||
type: sanction.type,
|
||||
username: sanction.username,
|
||||
uuid: sanction.uuid,
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify requires Body instead of body.
|
||||
server.post<{ Body: { platform: string;
|
||||
uuid: string;
|
||||
username: string;
|
||||
type: string;
|
||||
reason: string; }; }>(
|
||||
"/sanction",
|
||||
|
||||
async(request, reply) => {
|
||||
const token = request.headers.authorization;
|
||||
if (token === undefined || token !== process.env.ANNOUNCEMENT_TOKEN) {
|
||||
blockedIps.push({
|
||||
ip: getIpFromRequest(request),
|
||||
ttl: new Date(Date.now() + oneDay),
|
||||
});
|
||||
return await reply.status(401).send({
|
||||
error:
|
||||
// eslint-disable-next-line stylistic/max-len -- Big boi string.
|
||||
"This endpoint requires a special auth token. If you believe you should have access, please contact Naomi. To protect our services, your IP has been blocked from all routes for 24 hours.",
|
||||
});
|
||||
}
|
||||
|
||||
const { platform, uuid, username, type, reason } = request.body;
|
||||
if (
|
||||
[ platform, uuid, username, type, reason ].some((value) => {
|
||||
return !isValidString(value);
|
||||
})
|
||||
) {
|
||||
return await reply.status(400).send({
|
||||
error: "Missing required fields.",
|
||||
});
|
||||
}
|
||||
|
||||
if (![
|
||||
"warning",
|
||||
"kick",
|
||||
"mute",
|
||||
"ban",
|
||||
].includes(type)) {
|
||||
return await reply.status(400).send({
|
||||
error: "Invalid type. Choose from warning, kick, mute, ban.",
|
||||
});
|
||||
}
|
||||
|
||||
const count = await database.getInstance().sanctions.count();
|
||||
const number = count + 1;
|
||||
|
||||
await database.getInstance().sanctions.create({
|
||||
data: {
|
||||
number,
|
||||
platform,
|
||||
reason,
|
||||
type,
|
||||
username,
|
||||
uuid,
|
||||
},
|
||||
});
|
||||
|
||||
const components = getSanctionComponents(
|
||||
{
|
||||
number,
|
||||
platform,
|
||||
reason,
|
||||
type,
|
||||
username,
|
||||
uuid,
|
||||
},
|
||||
);
|
||||
|
||||
await fetch(
|
||||
`${process.env.SANCTION_WEBHOOK ?? ""}?with_components=true`,
|
||||
{
|
||||
body: JSON.stringify({
|
||||
components: components,
|
||||
flags: 32_768,
|
||||
}),
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention -- headers.
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
|
||||
return await reply.status(201).send({
|
||||
message: `Sanction ${number.toString()} has been logged!`,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @copyright NHCarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks that a nullable value is a string and has length.
|
||||
* @param maybeString -- The nullable value to check.
|
||||
* @returns True if it is a string.
|
||||
*/
|
||||
export const isValidString = (maybeString: unknown): maybeString is string => {
|
||||
return typeof maybeString === "string" && maybeString.length > 0;
|
||||
};
|
||||
Reference in New Issue
Block a user