feat: client and server logic to manage announcements (#3)
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:
2025-07-05 19:27:20 -07:00
committed by Naomi Carrigan
parent a12f2b0315
commit 37081cab76
27 changed files with 2012 additions and 107 deletions

View File

@ -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

View File

@ -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"

View 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";
}>;
}
}

View 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;
}

View 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>

View 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;
});
}
}

View File

@ -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: "**" },
];

View File

@ -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;

View File

@ -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>

View File

@ -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

File diff suppressed because it is too large Load Diff

5
server/dev.env Normal file
View 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"

View File

@ -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",
}
}
]

View File

@ -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"
}
}

View 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
}

View File

@ -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
View File

@ -0,0 +1,7 @@
/**
* @copyright nhcarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export const blockedIps: Array<{ ip: string; ttl: Date }> = [];

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 };

42
server/src/hooks/cors.ts Normal file
View 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
View 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;
};

View File

@ -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;
}
void logger.log("debug", "Server listening on port 20000.");
if (process.env.NODE_ENV !== "dev") {
void logger.log("debug", "Server listening on port 20000.");
}
});

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,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;
};

View 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
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~!");
});
};