generated from nhcarrigan/template
feat: add ability to log and display sanctions
This commit is contained in:
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint ./src --max-warnings 0",
|
||||
"lint": "eslint ./src --max-warnings 0 --ignore-pattern ./src/data",
|
||||
"build": "tsc",
|
||||
"start": "op run --env-file=./prod.env -- node ./prod/index.js",
|
||||
"test": "echo 'No tests yet' && exit 0"
|
||||
|
||||
+18649
File diff suppressed because one or more lines are too long
@@ -12,10 +12,13 @@ import { errorHandler } from "../utils/errorHandler.js";
|
||||
* @param hikari - Hikari's Discord instance.
|
||||
* @param message - The message payload from Discord.
|
||||
*/
|
||||
// eslint-disable-next-line max-lines-per-function -- This function is large, but it handles a lot of logic.
|
||||
// eslint-disable-next-line max-lines-per-function, complexity -- This function is large, but it handles a lot of logic.
|
||||
const guildMessageCreate = async (hikari, message) => {
|
||||
try {
|
||||
if (!hikari.user || !message.mentions.has(hikari.user.id)) {
|
||||
if (!hikari.user || !message.mentions.has(hikari.user.id, {
|
||||
ignoreEveryone: true,
|
||||
ignoreRoles: true,
|
||||
}) || message.author.bot) {
|
||||
return;
|
||||
}
|
||||
await message.channel.sendTyping();
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
<p>Here are the most recent updates for our products and communities.</p>
|
||||
<p>
|
||||
If you want to see the full history, check out our
|
||||
<a href="https://chat.nhcarrigan.com" target="_blank">chat server</a> or our
|
||||
<a href="https://forum.nhcarrigan.com" target="_blank">forum</a>.
|
||||
<a href="https://chat.nhcarrigan.com" target="_blank">chat server</a>.
|
||||
</p>
|
||||
<div class="announcement" *ngFor="let announcement of announcements">
|
||||
<hr />
|
||||
|
||||
@@ -8,11 +8,13 @@ import { Routes } from "@angular/router";
|
||||
import { Announcements } from "./announcements/announcements.js";
|
||||
import { Home } from "./home/home.js";
|
||||
import { Products } from "./products/products.js";
|
||||
import { Sanctions } from "./sanctions/sanctions.js";
|
||||
import { Soon } from "./soon/soon.js";
|
||||
|
||||
export const routes: Routes = [
|
||||
{ component: Home, path: "", pathMatch: "full" },
|
||||
{ component: Products, path: "products" },
|
||||
{ component: Announcements, path: "announcements" },
|
||||
{ component: Sanctions, path: "sanctions" },
|
||||
{ component: Soon, path: "**" },
|
||||
];
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
<hr />
|
||||
<a routerLink="/products" class="nav-link">Products</a>
|
||||
<hr />
|
||||
<a routerLink="/sanctions" class="nav-link">Sanctions</a>
|
||||
<hr />
|
||||
<a routerLink="/account" class="nav-link">Account</a>
|
||||
<hr />
|
||||
<a routerLink="/settings" class="nav-link">Settings</a>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class SanctionsService {
|
||||
public constructor() {}
|
||||
// eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Getter for static URL.
|
||||
private get url(): string {
|
||||
return "http://localhost:20000/sanctions";
|
||||
}
|
||||
|
||||
public async getSanctions(): Promise<
|
||||
Array<{
|
||||
number: number;
|
||||
uuid: string;
|
||||
type: string;
|
||||
platform: string;
|
||||
reason: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
}>
|
||||
> {
|
||||
const response = await fetch(this.url);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await response.json()) as Array<{
|
||||
number: number;
|
||||
uuid: string;
|
||||
type: string;
|
||||
platform: string;
|
||||
reason: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
hr {
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top: 1px solid var(--foreground);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:host ::ng-deep ul{
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.sanction {
|
||||
margin: auto;
|
||||
margin-bottom: 1em;
|
||||
width: 90%;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0 0.5em;
|
||||
border-radius: 50px;
|
||||
font-size: 0.8em;
|
||||
background-color: #e0f7fa;
|
||||
color: #006064;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<h1>Sanctions</h1>
|
||||
<p>Here are the most recent moderation actions taken to keep our community safe.</p>
|
||||
<p>
|
||||
If you want to see the full history, check out our
|
||||
<a href="https://chat.nhcarrigan.com" target="_blank">chat server</a>.
|
||||
</p>
|
||||
<div class="sanction" *ngFor="let sanction of sanctions">
|
||||
<hr />
|
||||
<h2>Case #{{ sanction.number }}: {{ sanction.type.toUpperCase() }}</h2>
|
||||
<p>
|
||||
<span class="tag">{{sanction.platform}}</span>
|
||||
<span class="date"> {{ sanction.createdAt | date: "mediumDate" }}</span>
|
||||
</p>
|
||||
<p>{{ sanction.reason }}</p>
|
||||
<ul class="metadata">
|
||||
<li>Username: {{ sanction.username }}</li>
|
||||
<li>UUID: {{ sanction.uuid }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="no-sanctions" *ngIf="!sanctions.length">
|
||||
<p>There are no sanctions at this time.</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { CommonModule, DatePipe } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { SanctionsService } from "../sanctions.js";
|
||||
|
||||
@Component({
|
||||
imports: [ CommonModule, DatePipe ],
|
||||
selector: "app-sanctions",
|
||||
styleUrl: "./sanctions.css",
|
||||
templateUrl: "./sanctions.html",
|
||||
})
|
||||
export class Sanctions {
|
||||
public sanctions: Array<{
|
||||
number: number;
|
||||
uuid: string;
|
||||
type: string;
|
||||
platform: string;
|
||||
reason: string;
|
||||
username: string;
|
||||
createdAt: string;
|
||||
}> = [];
|
||||
public constructor(
|
||||
private readonly sanctionsService: SanctionsService,
|
||||
) {
|
||||
void this.loadSanctions();
|
||||
}
|
||||
|
||||
private async loadSanctions(): Promise<void> {
|
||||
const sanctions = await this.sanctionsService.getSanctions();
|
||||
this.sanctions = sanctions.sort((a, b) => {
|
||||
return b.createdAt > a.createdAt
|
||||
? 1
|
||||
: -1;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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