feat: set up announcement and cors logic for server

This commit is contained in:
2025-07-05 15:43:23 -07:00
parent a12f2b0315
commit 42bad8c6c8
14 changed files with 548 additions and 102 deletions

View File

@ -0,0 +1,15 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/**
* If you want a route to allow any origin for CORS, add
* the full path to this array.
*/
export const routesWithoutCors = [
"/",
"/announcement",
"/health",
];

24
server/src/db/database.ts Normal file
View File

@ -0,0 +1,24 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { PrismaClient } from "@prisma/client";
class Database {
private readonly instance: PrismaClient;
public constructor() {
this.instance = new PrismaClient();
void this.instance.$connect();
}
public getInstance(): PrismaClient {
return this.instance;
}
}
const database = new Database();
export { database };

28
server/src/hooks/cors.ts Normal file
View File

@ -0,0 +1,28 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { onRequestHookHandler } from "fastify";
/**
* Ensures that form submissions only come from our web application.
* @param request - The request payload from the server.
* @param response - The reply handler from Fastify.
* @returns A Fastify reply if the request is invalid, otherwise undefined.
*/
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- For reasons I cannot comprehend, Fastify seems to require us to return a request?
export const corsHook: onRequestHookHandler = async(request, response) => {
if (!request.url.startsWith("/submit")) {
return undefined;
}
if (request.headers.origin !== "https://forms.nhcarrigan.com") {
return await response.
status(403).
send({
error: "Forms can only be submitted through our website. Thanks.",
});
}
return undefined;
};

View File

@ -5,19 +5,19 @@
*/
import fastify from "fastify";
import { corsHook } from "./hooks/cors.js";
import { announcementRoutes } from "./routes/announcement.js";
import { baseRoutes } from "./routes/base.js";
import { logger } from "./utils/logger.js";
const server = fastify({
logger: false,
});
server.get("/", async(_request, reply) => {
reply.redirect("https://hikari.nhcarrigan.com");
});
server.addHook("preHandler", corsHook);
server.get("/health", async(_request, reply) => {
reply.status(200).send("OK~!");
});
server.register(baseRoutes);
server.register(announcementRoutes);
server.listen({ port: 20_000 }, (error) => {
if (error) {

View File

@ -0,0 +1,65 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- we are making raw API calls. */
const channelIds = {
community: "1386105484313886820",
products: "1386105452881776661",
} as const;
const roleIds = {
community: "1386107941224054895",
products: "1386107909699666121",
} as const;
/**
* Forwards an announcement to our Discord server.
* @param title - The title of the announcement.
* @param content - The main body of the announcement.
* @param type - Whether the announcement is for a product or community.
* @returns A message indicating the success or failure of the operation.
*/
export const announceOnDiscord = async(
title: string,
content: string,
type: "products" | "community",
): Promise<string> => {
const messageRequest = await fetch(
`https://discord.com/api/v10/channels/${channelIds[type]}/messages`,
{
body: JSON.stringify({
allowed_mentions: { parse: [ "users", "roles" ] },
content: `# ${title}\n\n${content}\n-# <@&${roleIds[type]}>`,
}),
headers: {
"Authorization": `Bot ${process.env.DISCORD_TOKEN ?? ""}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (messageRequest.status !== 200) {
return "Failed to send message to Discord.";
}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- fetch does not accept generics.
const message = await messageRequest.json() as { id?: string };
if (message.id === undefined) {
return "Failed to parse message ID, cannot crosspost.";
}
const crosspostRequest = await fetch(
`https://discord.com/api/v10/channels/${channelIds[type]}/messages/${message.id}/crosspost`,
{
headers: {
"Authorization": `Bot ${process.env.DISCORD_TOKEN ?? ""}`,
"Content-Type": "application/json",
},
method: "POST",
},
);
if (!crosspostRequest.ok) {
return "Failed to crosspost message to Discord.";
}
return "Successfully sent and published message to Discord.";
};

View File

@ -0,0 +1,40 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable @typescript-eslint/naming-convention -- we are making raw API calls. */
/**
* Forwards an announcement to our Discord server.
* @param title - The title of the announcement.
* @param content - The main body of the announcement.
* @param type - Whether the announcement is for a product or community.
* @returns A message indicating the success or failure of the operation.
*/
export const announceOnForum = async(
title: string,
content: string,
type: "products" | "community",
): Promise<string> => {
const forumRequest = await fetch(
`https://forum.nhcarrigan.com/posts.json`,
{
body: JSON.stringify({
category: 14,
raw: content,
tags: [ type ],
title: title,
}),
headers: {
"Api-Key": process.env.FORUM_API_KEY ?? "",
"Api-Username": "Hikari",
"Content-Type": "application/json",
},
method: "POST",
},
);
if (forumRequest.status !== 200) {
return "Failed to send message to forum.";
}
return "Successfully sent message to forum.";
};

View File

@ -0,0 +1,95 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { database } from "../db/database.js";
import { announceOnDiscord } from "../modules/announceOnDiscord.js";
import { announceOnForum } from "../modules/announceOnForum.js";
import type { FastifyPluginAsync } from "fastify";
/**
* 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 announcementRoutes: FastifyPluginAsync = async(server) => {
server.get("/announcements", async(_request, reply) => {
const announcements = await database.getInstance().announcements.findMany({
orderBy: {
createdAt: "desc",
},
take: 10,
});
return await reply.status(200).type("application/json").
send(announcements);
});
// eslint-disable-next-line @typescript-eslint/naming-convention -- Fastify requires Body instead of body.
server.post<{ Body: { title: string; content: string; type: string } }>(
"/announcement",
// eslint-disable-next-line complexity -- This is a complex route, but it is necessary to validate the announcement.
async(request, reply) => {
const token = request.headers.authorization;
if (token === undefined || token !== process.env.ANNOUNCEMENT_TOKEN) {
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.",
});
}
const { title, content, type } = request.body;
if (
typeof title !== "string"
|| typeof content !== "string"
|| typeof type !== "string"
|| title.length === 0
|| content.length === 0
|| type.length === 0
) {
return await reply.status(400).send({
error: "Missing required fields.",
});
}
if (title.length < 20) {
return await reply.status(400).send({
error:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Title must be at least 20 characters long so that it may be posted on our forum.",
});
}
if (content.length < 50) {
return await reply.status(400).send({
error:
// eslint-disable-next-line stylistic/max-len -- Big boi string.
"Content must be at least 50 characters long so that it may be posted on our forum.",
});
}
if (type !== "products" && type !== "community") {
return await reply.status(400).send({
error: "Invalid announcement type.",
});
}
await database.getInstance().announcements.create({
data: {
content,
title,
type,
},
});
const discord = await announceOnDiscord(title, content, type);
const forum = await announceOnForum(title, content, type);
return await reply.status(201).send({
message: `Announcement processed. Discord: ${discord}, Forum: ${forum}`,
});
},
);
};

23
server/src/routes/base.ts Normal file
View File

@ -0,0 +1,23 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { FastifyPluginAsync } from "fastify";
/**
* 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 baseRoutes: FastifyPluginAsync = async(server) => {
server.get("/", async(_request, reply) => {
return await reply.redirect("https://hikari.nhcarrigan.com");
});
server.get("/health", async(_request, reply) => {
return await reply.status(200).send("OK~!");
});
};