feat: add ability to log and display sanctions

This commit is contained in:
2025-08-25 15:55:48 -07:00
parent a9126ec826
commit 908f22d6aa
17 changed files with 19018 additions and 6 deletions
+1 -1
View File
@@ -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
View File
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -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 />
+2
View File
@@ -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: "**" },
];
+2
View File
@@ -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>
+44
View File
@@ -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;
}>;
}
}
+33
View File
@@ -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;
}
+23
View File
@@ -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>
+40
View File
@@ -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;
});
}
}
+11
View File
@@ -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
View File
@@ -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"
+1
View File
@@ -13,4 +13,5 @@ export const routesWithoutCors = [
"/announcement",
"/health",
"/mcp",
"/sanction",
];
+2
View File
@@ -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,
},
];
};
+132
View File
@@ -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!`,
});
},
);
};
+14
View File
@@ -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;
};