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",
|
"main": "index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint ./src --max-warnings 0",
|
"lint": "eslint ./src --max-warnings 0 --ignore-pattern ./src/data",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "op run --env-file=./prod.env -- node ./prod/index.js",
|
"start": "op run --env-file=./prod.env -- node ./prod/index.js",
|
||||||
"test": "echo 'No tests yet' && exit 0"
|
"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 hikari - Hikari's Discord instance.
|
||||||
* @param message - The message payload from Discord.
|
* @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) => {
|
const guildMessageCreate = async (hikari, message) => {
|
||||||
try {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
await message.channel.sendTyping();
|
await message.channel.sendTyping();
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
<p>Here are the most recent updates for our products and communities.</p>
|
<p>Here are the most recent updates for our products and communities.</p>
|
||||||
<p>
|
<p>
|
||||||
If you want to see the full history, check out our
|
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://chat.nhcarrigan.com" target="_blank">chat server</a>.
|
||||||
<a href="https://forum.nhcarrigan.com" target="_blank">forum</a>.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="announcement" *ngFor="let announcement of announcements">
|
<div class="announcement" *ngFor="let announcement of announcements">
|
||||||
<hr />
|
<hr />
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import { Routes } from "@angular/router";
|
|||||||
import { Announcements } from "./announcements/announcements.js";
|
import { Announcements } from "./announcements/announcements.js";
|
||||||
import { Home } from "./home/home.js";
|
import { Home } from "./home/home.js";
|
||||||
import { Products } from "./products/products.js";
|
import { Products } from "./products/products.js";
|
||||||
|
import { Sanctions } from "./sanctions/sanctions.js";
|
||||||
import { Soon } from "./soon/soon.js";
|
import { Soon } from "./soon/soon.js";
|
||||||
|
|
||||||
export const routes: Routes = [
|
export const routes: Routes = [
|
||||||
{ component: Home, path: "", pathMatch: "full" },
|
{ component: Home, path: "", pathMatch: "full" },
|
||||||
{ component: Products, path: "products" },
|
{ component: Products, path: "products" },
|
||||||
{ component: Announcements, path: "announcements" },
|
{ component: Announcements, path: "announcements" },
|
||||||
|
{ component: Sanctions, path: "sanctions" },
|
||||||
{ component: Soon, path: "**" },
|
{ component: Soon, path: "**" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
<hr />
|
<hr />
|
||||||
<a routerLink="/products" class="nav-link">Products</a>
|
<a routerLink="/products" class="nav-link">Products</a>
|
||||||
<hr />
|
<hr />
|
||||||
|
<a routerLink="/sanctions" class="nav-link">Sanctions</a>
|
||||||
|
<hr />
|
||||||
<a routerLink="/account" class="nav-link">Account</a>
|
<a routerLink="/account" class="nav-link">Account</a>
|
||||||
<hr />
|
<hr />
|
||||||
<a routerLink="/settings" class="nav-link">Settings</a>
|
<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
|
content String
|
||||||
type String
|
type String
|
||||||
createdAt DateTime @default(now()) @unique
|
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_SECRET="op://Environment Variables - Naomi/Hikari/twitter_access_secret"
|
||||||
TWITTER_CONSUMER_KEY="op://Environment Variables - Naomi/Hikari/twitter_consumer_key"
|
TWITTER_CONSUMER_KEY="op://Environment Variables - Naomi/Hikari/twitter_consumer_key"
|
||||||
TWITTER_CONSUMER_SECRET="op://Environment Variables - Naomi/Hikari/twitter_consumer_secret"
|
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",
|
"/announcement",
|
||||||
"/health",
|
"/health",
|
||||||
"/mcp",
|
"/mcp",
|
||||||
|
"/sanction",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ipHook } from "./hooks/ips.js";
|
|||||||
import { announcementRoutes } from "./routes/announcement.js";
|
import { announcementRoutes } from "./routes/announcement.js";
|
||||||
import { baseRoutes } from "./routes/base.js";
|
import { baseRoutes } from "./routes/base.js";
|
||||||
import { mcpRoutes } from "./routes/mcp.js";
|
import { mcpRoutes } from "./routes/mcp.js";
|
||||||
|
import { sanctionRoutes } from "./routes/sanction.js";
|
||||||
import { logger } from "./utils/logger.js";
|
import { logger } from "./utils/logger.js";
|
||||||
|
|
||||||
const server = fastify({
|
const server = fastify({
|
||||||
@@ -34,6 +35,7 @@ server.addHook("preHandler", ipHook);
|
|||||||
server.register(baseRoutes);
|
server.register(baseRoutes);
|
||||||
server.register(announcementRoutes);
|
server.register(announcementRoutes);
|
||||||
server.register(mcpRoutes);
|
server.register(mcpRoutes);
|
||||||
|
server.register(sanctionRoutes);
|
||||||
|
|
||||||
server.listen({ port: 20_000 }, (error) => {
|
server.listen({ port: 20_000 }, (error) => {
|
||||||
if (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