generated from nhcarrigan/template
feat: client and server logic to manage announcements (#3)
All checks were successful
Node.js CI / Lint and Test (push) Successful in 1m9s
All checks were successful
Node.js CI / Lint and Test (push) Successful in 1m9s
### Explanation _No response_ ### Issue _No response_ ### Attestations - [x] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [x] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [x] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [x] I have pinned the dependencies to a specific patch version. ### Style - [x] I have run the linter and resolved any errors. - [x] My pull request uses an appropriate title, matching the conventional commit standards. - [x] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Reviewed-on: #3 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit is contained in:
@ -28,6 +28,9 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Generate Database Schema
|
||||
run: cd server && pnpm prisma generate
|
||||
|
||||
- name: Lint Source Files
|
||||
run: pnpm run lint
|
||||
|
||||
|
@ -28,6 +28,7 @@
|
||||
"@angular/forms": "20.0.6",
|
||||
"@angular/platform-browser": "20.0.6",
|
||||
"@angular/router": "20.0.6",
|
||||
"ngx-markdown": "20.0.0",
|
||||
"rxjs": "7.8.2",
|
||||
"tslib": "2.8.1",
|
||||
"zone.js": "0.15.1"
|
||||
|
38
client/src/app/announcements.ts
Normal file
38
client/src/app/announcements.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class AnnouncementsService {
|
||||
public constructor() {}
|
||||
// eslint-disable-next-line @typescript-eslint/class-methods-use-this -- Getter for static URL.
|
||||
private get url(): string {
|
||||
return "http://localhost:20000/announcements";
|
||||
}
|
||||
|
||||
public async getAnnouncements(): Promise<
|
||||
Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
type: "products" | "community";
|
||||
}>
|
||||
> {
|
||||
const response = await fetch(this.url);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (await response.json()) as Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
type: "products" | "community";
|
||||
}>;
|
||||
}
|
||||
}
|
37
client/src/app/announcements/announcements.css
Normal file
37
client/src/app/announcements/announcements.css
Normal file
@ -0,0 +1,37 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.announcement {
|
||||
margin: auto;
|
||||
margin-bottom: 1em;
|
||||
width: 90%;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 0 0.5em;
|
||||
border-radius: 50px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.products {
|
||||
background-color: #e0f7fa;
|
||||
color: #006064;
|
||||
}
|
||||
|
||||
.community {
|
||||
background-color: #e8f5e9;
|
||||
color: #1b5e20;
|
||||
}
|
||||
|
||||
.date {
|
||||
font-style: italic;
|
||||
}
|
20
client/src/app/announcements/announcements.html
Normal file
20
client/src/app/announcements/announcements.html
Normal file
@ -0,0 +1,20 @@
|
||||
<h1>Announcements</h1>
|
||||
<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>.
|
||||
</p>
|
||||
<div class="announcement" *ngFor="let announcement of announcements">
|
||||
<hr />
|
||||
<h2>{{ announcement.title }}</h2>
|
||||
<p>
|
||||
<span [class]="'tag ' + announcement.type">{{announcement.type}}</span>
|
||||
<span class="date"> {{ announcement.createdAt | date: "mediumDate" }}</span>
|
||||
</p>
|
||||
<markdown [data]="announcement.content"></markdown>
|
||||
<p class="type">Type: {{ announcement.type }}</p>
|
||||
</div>
|
||||
<div class="no-announcements" *ngIf="!announcements.length">
|
||||
<p>There are no announcements at this time.</p>
|
||||
</div>
|
39
client/src/app/announcements/announcements.ts
Normal file
39
client/src/app/announcements/announcements.ts
Normal file
@ -0,0 +1,39 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
import { CommonModule, DatePipe } from "@angular/common";
|
||||
import { Component, SecurityContext } from "@angular/core";
|
||||
import { MarkdownComponent, provideMarkdown } from "ngx-markdown";
|
||||
import { AnnouncementsService } from "../announcements.js";
|
||||
|
||||
@Component({
|
||||
imports: [ CommonModule, DatePipe, MarkdownComponent ],
|
||||
providers: [ provideMarkdown({ sanitize: SecurityContext.HTML }) ],
|
||||
selector: "app-announcements",
|
||||
styleUrl: "./announcements.css",
|
||||
templateUrl: "./announcements.html",
|
||||
})
|
||||
export class Announcements {
|
||||
public announcements: Array<{
|
||||
title: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
type: "products" | "community";
|
||||
}> = [];
|
||||
public constructor(
|
||||
private readonly announcementsService: AnnouncementsService,
|
||||
) {
|
||||
void this.loadAnnouncements();
|
||||
}
|
||||
|
||||
private async loadAnnouncements(): Promise<void> {
|
||||
const announcements = await this.announcementsService.getAnnouncements();
|
||||
this.announcements = announcements.sort((a, b) => {
|
||||
return b.createdAt > a.createdAt
|
||||
? 1
|
||||
: -1;
|
||||
});
|
||||
}
|
||||
}
|
@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { Routes } from "@angular/router";
|
||||
import { Announcements } from "./announcements/announcements.js";
|
||||
import { Home } from "./home/home.js";
|
||||
import { Products } from "./products/products.js";
|
||||
import { Soon } from "./soon/soon.js";
|
||||
@ -12,5 +13,6 @@ import { Soon } from "./soon/soon.js";
|
||||
export const routes: Routes = [
|
||||
{ component: Home, path: "", pathMatch: "full" },
|
||||
{ component: Products, path: "products" },
|
||||
{ component: Announcements, path: "announcements" },
|
||||
{ component: Soon, path: "**" },
|
||||
];
|
||||
|
@ -4,6 +4,11 @@ ul {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
::ng-deep main{
|
||||
overflow: hidden !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
#one {
|
||||
transform: translateY(-200vh);
|
||||
animation: slide-down 2s forwards;
|
||||
@ -35,9 +40,14 @@ ul {
|
||||
animation: slide-right 2s forwards 10s;
|
||||
}
|
||||
|
||||
#seven {
|
||||
transform: translateX(-200vw);
|
||||
animation: slide-left 2s forwards 12s;
|
||||
}
|
||||
|
||||
#fade {
|
||||
opacity: 0;
|
||||
animation: fade-in 2s forwards 12s;
|
||||
animation: fade-in 2s forwards 14s;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
|
@ -7,12 +7,14 @@
|
||||
<p id="one">How may I help you today?</p>
|
||||
<p id="two">I can assist you with:</p>
|
||||
<ul>
|
||||
<li id="three">Finding a product to suit your needs</li>
|
||||
<li id="four">Manage your account, subscriptions, and licenses</li>
|
||||
<li id="five">Modifying settings for individual products</li>
|
||||
<li id="six">Answering your specific questions with a chat assistant</li>
|
||||
<li id="three">Checking the latest updates.</li>
|
||||
<li id="four">Finding a product to suit your needs</li>
|
||||
<li id="five">Manage your account, subscriptions, and licenses</li>
|
||||
<li id="six">Modifying settings for individual products</li>
|
||||
<li id="seven">Answering your specific questions with a chat assistant</li>
|
||||
</ul>
|
||||
<div id="fade">
|
||||
<a routerLink="/announcements" class="btn">View Announcements</a>
|
||||
<a routerLink="/products" class="btn">Browse Products</a>
|
||||
<a routerLink="/account" class="btn">Manage Account</a>
|
||||
<a routerLink="/settings" class="btn">Modify Settings</a>
|
||||
|
@ -5,6 +5,8 @@
|
||||
></a
|
||||
>
|
||||
<div [class]="dropdownClass">
|
||||
<a routerLink="/announcements" class="nav-link">Announcements</a>
|
||||
<hr />
|
||||
<a routerLink="/products" class="nav-link">Products</a>
|
||||
<hr />
|
||||
<a routerLink="/account" class="nav-link">Account</a>
|
||||
|
1490
pnpm-lock.yaml
generated
1490
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
5
server/dev.env
Normal file
5
server/dev.env
Normal file
@ -0,0 +1,5 @@
|
||||
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||
MONGO_URI="op://Environment Variables - Naomi/Hikari/mongo_uri"
|
||||
DISCORD_TOKEN="op://Environment Variables - Naomi/Hikari/discord_token"
|
||||
FORUM_API_KEY="op://Environment Variables - Naomi/Hikari/discourse_key"
|
||||
ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token"
|
@ -1,5 +1,18 @@
|
||||
import NaomisConfig from '@nhcarrigan/eslint-config';
|
||||
|
||||
export default [
|
||||
...NaomisConfig
|
||||
...NaomisConfig,
|
||||
{
|
||||
files: ["src/routes/*.ts"],
|
||||
rules: {
|
||||
"max-lines-per-function": "off",
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ["src/routes/*.ts"],
|
||||
rules: {
|
||||
// We turn this off so we can use the async plugin syntax without needing to await.
|
||||
"@typescript-eslint/require-await": "off",
|
||||
}
|
||||
}
|
||||
]
|
@ -6,6 +6,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint ./src --max-warnings 0",
|
||||
"dev": "NODE_ENV=dev op run --env-file=./dev.env -- tsx watch ./src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "op run --env-file=./prod.env -- node ./prod/index.js",
|
||||
"test": "echo 'No tests yet' && exit 0"
|
||||
@ -15,10 +16,14 @@
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.12.3",
|
||||
"dependencies": {
|
||||
"@fastify/cors": "11.0.1",
|
||||
"@nhcarrigan/logger": "1.0.0",
|
||||
"@prisma/client": "6.11.1",
|
||||
"fastify": "5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "24.0.10"
|
||||
"@types/node": "24.0.10",
|
||||
"prisma": "6.11.1",
|
||||
"tsx": "4.20.3"
|
||||
}
|
||||
}
|
||||
|
19
server/prisma/schema.prisma
Normal file
19
server/prisma/schema.prisma
Normal file
@ -0,0 +1,19 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mongodb"
|
||||
url = env("MONGO_URI")
|
||||
}
|
||||
|
||||
model Announcements {
|
||||
id String @id @default(auto()) @map("_id") @db.ObjectId
|
||||
title String
|
||||
content String
|
||||
type String
|
||||
createdAt DateTime @default(now()) @unique
|
||||
}
|
@ -1 +1,5 @@
|
||||
LOG_TOKEN="op://Environment Variables - Naomi/Alert Server/api_auth"
|
||||
MONGO_URI="op://Environment Variables - Naomi/Hikari/mongo_uri"
|
||||
DISCORD_TOKEN="op://Environment Variables - Naomi/Hikari/discord_token"
|
||||
FORUM_API_KEY="op://Environment Variables - Naomi/Hikari/discourse_key"
|
||||
ANNOUNCEMENT_TOKEN="op://Environment Variables - Naomi/Hikari/announcement_token"
|
7
server/src/cache/blockedIps.ts
vendored
Normal file
7
server/src/cache/blockedIps.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
export const blockedIps: Array<{ ip: string; ttl: Date }> = [];
|
15
server/src/config/routesWithoutCors.ts
Normal file
15
server/src/config/routesWithoutCors.ts
Normal 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
24
server/src/db/database.ts
Normal 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 };
|
42
server/src/hooks/cors.ts
Normal file
42
server/src/hooks/cors.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { routesWithoutCors } from "../config/routesWithoutCors.js";
|
||||
import type { onRequestHookHandler } from "fastify";
|
||||
|
||||
const isValidOrigin = (origin: string | undefined): boolean => {
|
||||
if (origin === undefined) {
|
||||
// We do not allow server-to-server requests.
|
||||
return false;
|
||||
}
|
||||
if (process.env.NODE_ENV === "dev" && origin === "http://localhost:4200") {
|
||||
// We allow the client to access the server when both are running locally.
|
||||
return true;
|
||||
}
|
||||
// Otherwise, we only allow requests from our web application.
|
||||
return origin === "https://hikari.nhcarrigan.com";
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 (routesWithoutCors.includes(request.url)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isValidOrigin(request.headers.origin)) {
|
||||
return await response.status(403).send({
|
||||
error:
|
||||
// eslint-disable-next-line stylistic/max-len -- This is a long error message.
|
||||
"This route is only accessible from our dashboard at https://hikari.nhcarrigan.com.",
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
};
|
36
server/src/hooks/ips.ts
Normal file
36
server/src/hooks/ips.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { blockedIps } from "../cache/blockedIps.js";
|
||||
import { getIpFromRequest } from "../modules/getIpFromRequest.js";
|
||||
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 ipHook: onRequestHookHandler = async(request, response) => {
|
||||
const ip = getIpFromRequest(request);
|
||||
const ipRecord = blockedIps.find(
|
||||
(record) => {
|
||||
return record.ip === ip && record.ttl > new Date();
|
||||
},
|
||||
);
|
||||
if (ipRecord && ipRecord.ttl > new Date()) {
|
||||
return await response.
|
||||
status(403).
|
||||
send({
|
||||
error: `Your IP address (${ipRecord.ip}) has been blocked until ${ipRecord.ttl.toISOString()}, to protect our API against brute-force attacks.`,
|
||||
});
|
||||
}
|
||||
if (ipRecord && ipRecord.ttl <= new Date()) {
|
||||
blockedIps.splice(blockedIps.indexOf(ipRecord), 1);
|
||||
}
|
||||
return undefined;
|
||||
};
|
@ -4,25 +4,41 @@
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import cors from "@fastify/cors";
|
||||
import fastify from "fastify";
|
||||
import { corsHook } from "./hooks/cors.js";
|
||||
import { ipHook } from "./hooks/ips.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");
|
||||
/**
|
||||
* This needs to be first, to ensure all requests have CORS configured.
|
||||
* Our CORS settings allow for any origin, because we have a custom hook
|
||||
* that guards specific routes from CORS requests.
|
||||
* This is to allow our uptime monitor to access the health check route, for example.
|
||||
* @see routesWithoutCors.ts
|
||||
*/
|
||||
server.register(cors, {
|
||||
origin: "*",
|
||||
});
|
||||
|
||||
server.get("/health", async(_request, reply) => {
|
||||
reply.status(200).send("OK~!");
|
||||
});
|
||||
server.addHook("preHandler", corsHook);
|
||||
server.addHook("preHandler", ipHook);
|
||||
|
||||
server.register(baseRoutes);
|
||||
server.register(announcementRoutes);
|
||||
|
||||
server.listen({ port: 20_000 }, (error) => {
|
||||
if (error) {
|
||||
void logger.error("instantiate server", error);
|
||||
return;
|
||||
}
|
||||
if (process.env.NODE_ENV !== "dev") {
|
||||
void logger.log("debug", "Server listening on port 20000.");
|
||||
}
|
||||
});
|
||||
|
65
server/src/modules/announceOnDiscord.ts
Normal file
65
server/src/modules/announceOnDiscord.ts
Normal 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.";
|
||||
};
|
40
server/src/modules/announceOnForum.ts
Normal file
40
server/src/modules/announceOnForum.ts
Normal 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.";
|
||||
};
|
25
server/src/modules/getIpFromRequest.ts
Normal file
25
server/src/modules/getIpFromRequest.ts
Normal file
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import type { FastifyRequest } from "fastify";
|
||||
|
||||
/**
|
||||
* Parses an IP address from a request, first looking for the
|
||||
* Cloudflare headers, then falling back to the request IP.
|
||||
* @param request - The Fastify request object.
|
||||
* @returns The IP address as a string.
|
||||
*/
|
||||
export const getIpFromRequest = (request: FastifyRequest): string => {
|
||||
const header
|
||||
= request.headers["X-Forwarded-For"] ?? request.headers["Cf-Connecting-IP"];
|
||||
if (typeof header === "string") {
|
||||
return header;
|
||||
}
|
||||
if (Array.isArray(header)) {
|
||||
return header[0] ?? header.join(", ");
|
||||
}
|
||||
return request.ip;
|
||||
};
|
110
server/src/routes/announcement.ts
Normal file
110
server/src/routes/announcement.ts
Normal file
@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @copyright nhcarrigan
|
||||
* @license Naomi's Public License
|
||||
* @author Naomi Carrigan
|
||||
*/
|
||||
|
||||
import { blockedIps } from "../cache/blockedIps.js";
|
||||
import { database } from "../db/database.js";
|
||||
import { announceOnDiscord } from "../modules/announceOnDiscord.js";
|
||||
import { announceOnForum } from "../modules/announceOnForum.js";
|
||||
import { getIpFromRequest } from "../modules/getIpFromRequest.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 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.map((announcement) => {
|
||||
return {
|
||||
content: announcement.content,
|
||||
createdAt: announcement.createdAt,
|
||||
title: announcement.title,
|
||||
type: announcement.type,
|
||||
};
|
||||
}));
|
||||
});
|
||||
|
||||
// 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) {
|
||||
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 { 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
23
server/src/routes/base.ts
Normal 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~!");
|
||||
});
|
||||
};
|
Reference in New Issue
Block a user