Here are the most recent updates for our products and communities.
diff --git a/client/src/app/app.routes.ts b/client/src/app/app.routes.ts
index 4a58aa6..136a65d 100644
--- a/client/src/app/app.routes.ts
+++ b/client/src/app/app.routes.ts
@@ -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: "**" },
];
diff --git a/client/src/app/nav/nav.html b/client/src/app/nav/nav.html
index d2a515f..1536924 100644
--- a/client/src/app/nav/nav.html
+++ b/client/src/app/nav/nav.html
@@ -9,6 +9,8 @@
Products
+
Sanctions
+
Account
Settings
diff --git a/client/src/app/sanctions.ts b/client/src/app/sanctions.ts
new file mode 100644
index 0000000..ba2c136
--- /dev/null
+++ b/client/src/app/sanctions.ts
@@ -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;
+ }>;
+ }
+}
diff --git a/client/src/app/sanctions/sanctions.css b/client/src/app/sanctions/sanctions.css
new file mode 100644
index 0000000..002525b
--- /dev/null
+++ b/client/src/app/sanctions/sanctions.css
@@ -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;
+}
diff --git a/client/src/app/sanctions/sanctions.html b/client/src/app/sanctions/sanctions.html
new file mode 100644
index 0000000..507c73a
--- /dev/null
+++ b/client/src/app/sanctions/sanctions.html
@@ -0,0 +1,23 @@
+
Sanctions
+
Here are the most recent moderation actions taken to keep our community safe.
+
+ If you want to see the full history, check out our
+ chat server.
+
+
+
+
Case #{{ sanction.number }}: {{ sanction.type.toUpperCase() }}
+
+ {{sanction.platform}}
+ {{ sanction.createdAt | date: "mediumDate" }}
+
+
{{ sanction.reason }}
+
+
+
+
There are no sanctions at this time.
+
+
diff --git a/client/src/app/sanctions/sanctions.ts b/client/src/app/sanctions/sanctions.ts
new file mode 100644
index 0000000..1611a3d
--- /dev/null
+++ b/client/src/app/sanctions/sanctions.ts
@@ -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
{
+ const sanctions = await this.sanctionsService.getSanctions();
+ this.sanctions = sanctions.sort((a, b) => {
+ return b.createdAt > a.createdAt
+ ? 1
+ : -1;
+ });
+ }
+}
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index 5044a32..952d099 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -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
}
\ No newline at end of file
diff --git a/server/prod.env b/server/prod.env
index 3e37e1c..be724e5 100644
--- a/server/prod.env
+++ b/server/prod.env
@@ -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"
\ No newline at end of file
+TWITTER_BEARER_TOKEN="op://Environment Variables - Naomi/Hikari/twitter_bearer_token"
+SANCTION_WEBHOOK="op://Environment Variables - Naomi/Hikari/sanction_webhook"
\ No newline at end of file
diff --git a/server/src/config/routesWithoutCors.ts b/server/src/config/routesWithoutCors.ts
index 9d7f154..e736ee9 100644
--- a/server/src/config/routesWithoutCors.ts
+++ b/server/src/config/routesWithoutCors.ts
@@ -13,4 +13,5 @@ export const routesWithoutCors = [
"/announcement",
"/health",
"/mcp",
+ "/sanction",
];
diff --git a/server/src/index.ts b/server/src/index.ts
index 070c460..aef6427 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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) {
diff --git a/server/src/modules/getSanctionComponents.ts b/server/src/modules/getSanctionComponents.ts
new file mode 100644
index 0000000..8030e13
--- /dev/null
+++ b/server/src/modules/getSanctionComponents.ts
@@ -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> => {
+ 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,
+ },
+ ];
+ };
diff --git a/server/src/routes/sanction.ts b/server/src/routes/sanction.ts
new file mode 100644
index 0000000..29c93ee
--- /dev/null
+++ b/server/src/routes/sanction.ts
@@ -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!`,
+ });
+ },
+ );
+};
diff --git a/server/src/utils/isValidString.ts b/server/src/utils/isValidString.ts
new file mode 100644
index 0000000..923b6cf
--- /dev/null
+++ b/server/src/utils/isValidString.ts
@@ -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;
+};