This commit is contained in:
metacryst
2026-01-04 07:58:23 -06:00
parent b50468eb5a
commit 6a435ac11a
122 changed files with 13995 additions and 19 deletions

89
server/auth.js Normal file
View File

@@ -0,0 +1,89 @@
import dotenv from 'dotenv';
import jwt from 'jsonwebtoken';
import argon2 from 'argon2';
dotenv.config();
export default class AuthHandler {
ips = new Map()
#secret
constructor() {
this.#secret = process.env.JWT_SECRET;
}
isLoggedInUser(req, res) {
const token = req.cookies.auth_token;
if (!token) {
return false;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
return true;
} catch (err) {
return false;
}
}
getProfile(req, res) {
const token = req.cookies.auth_token;
if (!token) return res.status(401).send({ error: "No auth token" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
const email = payload.email;
const user = db.members.getByEmail(email);
res.send({ email: user.email, name: user.firstName + " " + user.lastName });
} catch (err) {
res.status(401).send({ error: "Invalid token" });
}
}
async login(req, res) {
const { email, password } = req.body;
let foundUser = global.db.members.getByEmail(email)
if(!foundUser) {
res.status(400).json({ error: 'Incorrect email.' });
return;
}
const storedHash = foundUser.password
const valid = await argon2.verify(storedHash, password);
if (!valid) {
res.status(400).json({ error: 'Incorrect password.' });
} else {
const payload = { email: foundUser.email };
console.log(payload)
const secret = process.env.JWT_SECRET;
const options = { expiresIn: "2h" };
const token = jwt.sign(payload, secret, options);
res.cookie("auth_token", token, {
httpOnly: true, // cannot be accessed by JS
secure: process.env.ENV === "production", // only over HTTPS
sameSite: "lax", // like SameSiteLaxMode
maxAge: 2 * 60 * 60 * 1000, // 2 hours in milliseconds
path: "/", // available on entire site
domain: process.env.ENV === "production" ? "." + process.env.BASE_URL : undefined
});
res.redirect("/")
}
}
logout(req, res) {
res.cookie('auth_token', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0, // expire immediately
path: '/',
domain: process.env.ENV === "production" ? "." + process.env.BASE_URL : undefined
});
res.redirect("/")
}
}

107
server/db/db.js Normal file
View File

@@ -0,0 +1,107 @@
import fs from 'fs/promises';
import chalk from 'chalk';
import path from 'path';
import Titles from "./model/Titles.js"
import Members from './model/Members.js'
import Tokens from './model/Tokens.js'
import Payments from "./model/Payments.js"
import Posts from "./model/Forum/Posts.js"
import Conversations from "./model/Messages/Conversations.js"
import Messages from "./model/Messages/Messages.js"
export default class Database {
titles = new Titles()
members = new Members()
tokens = new Tokens()
payments = new Payments()
posts = new Posts()
conversations = new Conversations()
messages = new Messages()
fromID = {
"HY": this.titles,
"MEMBER": this.members,
"TOKEN": this.tokens,
"PAYMENT": this.payments,
"POST": this.posts,
"CONVERSATION": this.conversations,
"DM": this.messages
}
constructor() {
this.loadData()
}
async loadData() {
const dbData = await fs.readFile(path.join(process.cwd(), 'db/db.json'), 'utf8');
let dbJson;
try {
dbJson = JSON.parse(dbData);
} catch {
dbJson = []
}
let nodes = dbJson["nodes"];
let entries = Object.entries(nodes)
for(let i=0; i<entries.length; i++) {
let entry = entries[i]
let id = entry[0]; let node = entry[1];
let type = id.split("-")[0]
try {
let collection = this.fromID[type]
if(collection) {
collection.save(node, id)
} else {
throw new Error("Type does not exist for node: ", id)
}
} catch(e) {
throw e
}
}
setInterval(() => {
console.log("saving db")
global.db.saveData()
}, 5000)
}
async saveData() {
let data = {
"nodes": {
},
"edges": {
}
}
let arrs = [
this.titles.entries,
this.members.entries,
this.tokens.entries,
this.posts.entries,
this.conversations.entries,
this.messages.entries,
this.payments.entries,
]
let ids = [
Object.entries(this.titles.ids),
Object.entries(this.members.ids),
Object.entries(this.tokens.ids),
Object.entries(this.posts.ids),
Object.entries(this.conversations.ids),
Object.entries(this.messages.ids),
Object.entries(this.payments.ids),
]
for(let i=0; i<arrs.length; i++) {
let arr = arrs[i]
for(let j=0; j<arr.length; j++) {
data.nodes[ids[i][j][0]] = arr[j]
}
}
let string = JSON.stringify(data, null, 4)
await fs.writeFile(path.join(process.cwd(), 'db/db.json'), string, "utf8");
}
}

View File

@@ -0,0 +1,56 @@
import OrderedObject from "../OrderedObject.js"
import { z } from 'zod';
export default class Posts extends OrderedObject {
schema = z.object({
text: z.string(),
time: z.string(),
sentBy: z.string()
})
makeID(forum, number) {
return `POST-${forum}-${number}`
}
save(post, id) {
let result = this.schema.safeParse(post)
if(result.success) {
try {
super.add(id, post)
} catch(e) {
console.error(e)
throw e
}
} else {
console.error("Failed parsing member: ", result.error)
throw new global.ServerError(400, "Invalid Member Data!: ");
}
}
get(forum, number) {
let result = []
let limit = Math.min(number, this.entries.length)
for(let i=1; i<=limit; i++) {
let id = this.makeID(forum, i)
let post = this.entries[this.ids[id]]
let {firstName, lastName} = global.db.members.get(post.sentBy)
let seededObj = {
...post
}
seededObj.sentByID = post.sentBy
seededObj.sentBy = firstName + " " + lastName
result.push(seededObj)
}
return result
}
async add(text, forum, userEmail) {
let newPost = {}
newPost.text = text
newPost.sentBy = db.members.getIDFromEmail(userEmail)
newPost.time = global.currentTime()
let idNumber = this.entries.length+1
super.add(this.makeID(forum, idNumber), newPost)
}
}

View File

@@ -0,0 +1,77 @@
import OrderedObject from "./OrderedObject.js"
import argon2 from 'argon2';
import { z } from 'zod';
export default class Members extends OrderedObject {
addressSchema = z.object({
address1: z.string(),
address2: z.string().optional(),
zip: z.string().regex(/^\d{5}(-\d{4})?$/),
state: z.string(),
city: z.string()
})
schema = z.object({
id: z.number(),
email: z.string().email(),
firstName: z.string(),
lastName: z.string(),
password: z.string(),
tokenUsed: z.string().regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
"Invalid UUID"
),
joined: z.string(),
address: this.addressSchema,
})
isHashed = (s) => {return s.startsWith("$argon2")}
save(member) {
let id = `MEMBER-${member.id}`
let result = this.schema.safeParse(member)
if(result.success) {
try {
super.add(id, member)
} catch(e) {
console.error(e)
throw e
}
} else {
console.error("Failed parsing member: ", result.error)
throw new global.ServerError(400, "Invalid Member Data!: ");
}
}
async add(newMember, tokenID) {
newMember.tokenUsed = tokenID
const hash = await argon2.hash(newMember.password);
newMember.password = hash
newMember.joined = global.currentTime()
newMember.id = this.entries.length+1
this.save(newMember)
}
get(id) {
return this.entries[this.ids[id]]
}
getByEmail(email) {
for(let i=0; i<this.entries.length; i++) {
if(this.entries[i].email === email) {
return this.entries[i]
}
}
return null
}
getIDFromEmail(email) {
let index = 0
for(let i=0; i<this.entries.length; i++) {
if(this.entries[i].email === email) {
index = i
break
}
}
return Object.entries(this.ids)[index][0]
}
}

View File

@@ -0,0 +1,62 @@
import OrderedObject from "../OrderedObject.js"
import { z } from 'zod';
export default class Conversations extends OrderedObject {
schema = z.object({
id: z.number(),
between: z.array(z.string()),
lastUpdated: z.string()
}).strict()
save(convo) {
let id = `CONVERSATION-${convo.id}`
let result = this.schema.safeParse(convo)
if(result.success) {
try {
super.add(id, convo)
} catch(e) {
console.error(e)
throw e
}
} else {
console.error(result.error)
throw new global.ServerError(400, "Invalid Conversation Data!");
}
}
get(convoID) {
console.log("convo getting, ", convoID)
return this.entries[this.ids[convoID]]
}
getByMember(userID) {
let convos = []
function populateMemberProfilesFromIDs(ids) {
let result = []
for(let i=0; i<ids.length; i++) {
result[i] = global.db.members.get(ids[i])
}
return result
}
for(let i=0; i<this.entries.length; i++) {
let convo = this.entries[i]
console.log(convo, userID)
if(convo.between.includes(userID)) {
console.log("found user convo: ", convo.id)
let messages = global.db.messages.getByConversation(`CONVERSATION-${convo.id}`)
let result = {
...convo,
messages,
}
result.between = populateMemberProfilesFromIDs(convo.between)
convos.push(result)
}
}
return convos
}
}

View File

@@ -0,0 +1,57 @@
import OrderedObject from "../OrderedObject.js"
import { z } from 'zod';
export default class Messages extends OrderedObject {
schema = z.object({
id: z.number(),
conversation: z.string(),
from: z.string(),
text: z.string(),
time: z.string()
}).strict()
save(msg) {
let id = `DM-${msg.id}`
let result = this.schema.safeParse(msg)
if(result.success) {
try {
super.add(id, msg)
} catch(e) {
console.error(e)
throw e
}
} else {
console.error(result.error)
throw new global.ServerError(400, "Invalid Conversation Data!");
}
}
add(convo, text, userID) {
let newMessage = {}
newMessage.time = global.currentTime()
newMessage.from = userID
newMessage.conversation = convo
newMessage.text = text
newMessage.id = this.entries.length+1
console.log(newMessage)
this.save(newMessage)
}
getByConversation(convoID) {
let result = []
for(let i=0; i<this.entries.length; i++) {
let entry = this.entries[i]
if(entry.conversation = convoID) {
let userID = entry.from
let fromUser = global.db.members.get(userID)
let newObj = {
...entry
}
newObj.from = fromUser
result.push(newObj)
}
}
return result
}
}

View File

@@ -0,0 +1,27 @@
export default class OrderedObject {
entries = []
ids = {}
indexes = []
add(id, data) {
if(this.ids[id]) {
console.error(`Can't add item ${id}: id already exists`)
throw new global.ServerError(400, `Member with this email already exists`)
}
this.entries.push(data)
this.ids[id] = this.entries.length - 1
}
update(id, data) {
let index = this.ids[id]
this.entries[index] = data
}
delete(key) {
if (typeof key === "number") {
return this.entries[key]
} else {
return this.entries[this.ids[key]]
}
}
}

View File

@@ -0,0 +1,42 @@
import OrderedObject from "./OrderedObject.js"
import { z } from 'zod';
export default class Payments extends OrderedObject {
schema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
time: z.string(),
amount: z.number(),
product: z.string(),
})
save(payment) {
let id = `PAYMENT-${payment.id}`
let result = this.schema.safeParse(payment)
if(result.success) {
try {
super.add(id, payment)
} catch(e) {
console.error(e)
throw e
}
} else {
console.error(result.error)
throw new global.ServerError(400, "Invalid Member Data!");
}
}
add(paymentObj) {
let toSave = {
id: this.entries.length+1,
...paymentObj
}
this.save(toSave)
}
get(id) {
return this.entries[this.ids[`PAYMENT-${id}`]]
}
}

52
server/db/model/Titles.js Normal file
View File

@@ -0,0 +1,52 @@
import OrderedObject from "./OrderedObject.js"
export default class Titles extends OrderedObject {
save(newTitle) {
let id = `HY-${this.entries.length+1}`
if(this.validate(id, newTitle)) {
try {
super.add(id, newTitle)
} catch(e) {
console.error(e)
throw e
}
} else {
throw new global.ServerError(400, "Invalid Member Data!");
}
}
validate(id, node) {
let checkID = () => {
let split = id.split("-")
return (
split.length === 2
&& split[0] === "HY"
&& !isNaN(Number(split[1]))
)
}
let idres = checkID()
if(!idres) {
return false
}
let checkFields = () => {
let fields = [
"fullName",
]
for(let i = 0; i < fields.length; i++) {
if(!node[fields[i]]) {
throw new Error(`Title ${id} is missing trait ${fields[i]}`)
return false
}
}
return true
}
let fieldres = checkFields()
if(!fieldres) {
return false
}
return true
}
}

41
server/db/model/Tokens.js Normal file
View File

@@ -0,0 +1,41 @@
import OrderedObject from "./OrderedObject.js"
import { z } from 'zod';
export default class Tokens extends OrderedObject {
schema = z.object({
index: z.number(),
url: z.string(),
uuid: z.string().regex(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
"Invalid UUID"
),
used: z.boolean(),
})
markUsed(uuid) {
let token = this.get(uuid)
token.used = true
super.update(`TOKEN-${uuid}`, token)
}
save(token) {
let id = `TOKEN-${token.uuid}`
let result = this.schema.safeParse(token)
if(result.success) {
try {
super.add(id, token)
} catch(e) {
console.error(e)
throw e
}
} else {
console.error(result.error)
throw new global.ServerError(400, "Invalid Member Data!");
}
}
get(uuid) {
return this.entries[this.ids[`TOKEN-${uuid}`]]
}
}

210
server/index.js Normal file
View File

@@ -0,0 +1,210 @@
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import http from 'http';
import fs from 'fs';
import chalk from 'chalk';
import moment from 'moment';
import path from 'path';
import * as useragent from 'express-useragent';
import { fileURLToPath } from "url"
const __dirname = path.dirname(fileURLToPath(import.meta.url))
import "./util.js"
import Socket from './ws/ws.js'
import Database from "./db/db.js"
import AuthHandler from './auth.js';
import PaymentsHandler from "./payments.js"
class Server {
db;
auth;
UIPath = path.join(__dirname, '../ui')
DBPath = path.join(__dirname, '../db')
registerRoutes(router) {
/* Stripe */
router.post("/create-checkout-session", PaymentsHandler.danceTicket)
router.post("/webhook", express.raw({ type: "application/json" }), PaymentsHandler.webhook)
/* Auth */
router.post('/login', this.auth.login)
router.get('/profile', this.auth.getProfile)
router.get('/signout', this.auth.logout)
/* Site */
router.get('/signup', this.verifyToken, this.get)
router.post('/signup', this.verifyToken, this.newUserSubmission)
router.get('/db/images/*', this.getUserImage)
router.get('/*', this.get)
return router
}
verifyToken = (req, res, next) => {
const { token } = req.query;
if (!token) {
return res.status(400).json({ error: 'Token is required' });
}
let fromDB = this.db.tokens.get(token)
if (!fromDB) {
return res.status(403).json({ error: 'Invalid or expired token' });
} else if(fromDB.used) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
next()
}
newUserSubmission = async (req, res) => {
const { token } = req.query;
try {
let tokenData = db.tokens.get(token)
if(tokenData.used) throw new global.ServerError(400, "Token alredy used!")
await db.members.add(req.body, tokenData.uuid)
db.tokens.markUsed(token)
global.db.saveData()
return res.status(200).json({});
} catch(e) {
console.log(e)
return res.status(e.status).json({ error: 'Error adding new member' });
}
}
authMiddleware = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'Authorization token required.' });
}
const [scheme, token] = authHeader.split(' ');
if (scheme !== 'Bearer' || !token) {
return res.status(401).json({ error: 'Malformed authorization header.' })
}
try {
const payload = this.auth.verify(token);
req.user = payload;
return next();
} catch (err) {
return res.status(403).json({ error: 'Invalid or expired token.' });
}
}
getUserImage = async (req, res) => {
function getFileByNumber(dir, number) {
const files = fs.readdirSync(dir);
const match = files.find(file => {
const base = path.parse(file).name; // filename without extension
return base === String(number);
});
return match ? path.join(dir, match) : null;
}
let filePath = getFileByNumber(path.join(this.DBPath, "images"), path.basename(req.url))
res.sendFile(filePath)
}
get = async (req, res) => {
console.log("get")
let url = req.url
let publicSite = () => {
let filePath;
if(url.startsWith("/_")) {
filePath = path.join(this.UIPath, url);
} else if(url.includes("75820185")) {
filePath = path.join(this.UIPath, "public", url.split("75820185")[1]);
} else {
filePath = path.join(this.UIPath, "public", "index.html");
}
res.sendFile(filePath);
}
let privateSite = () => {
let filePath;
let platformFolder = req.useragent.isMobile ? "mobile" : "desktop"
if(url.startsWith("/_")) {
filePath = path.join(this.UIPath, url);
} else if(url.includes("75820185")) {
filePath = path.join(this.UIPath, platformFolder, url.split("75820185")[1]);
} else {
filePath = path.join(this.UIPath, platformFolder, "index.html");
}
res.sendFile(filePath);
}
if(!this.auth.isLoggedInUser(req, res)) {
publicSite()
} else {
privateSite()
}
}
logRequest(req, res, next) {
const formattedDate = moment().format('M.D');
const formattedTime = moment().format('h:mma');
if(req.url.includes("/api/")) {
console.log(chalk.blue(` ${req.method} ${req.url} | ${formattedDate} ${formattedTime}`));
} else {
if(req.url === "/")
console.log(chalk.gray(` ${req.method} ${req.url} | ${formattedDate} ${formattedTime}`));
}
next();
}
logResponse(req, res, next) {
const originalSend = res.send;
res.send = function (body) {
if(res.statusCode >= 400) {
console.log(chalk.blue( `<-${chalk.red(res.statusCode)}- ${req.method} ${req.url} | ${chalk.red(body)}`));
} else {
console.log(chalk.blue(`<-${res.statusCode}- ${req.method} ${req.url}`));
}
originalSend.call(this, body);
};
next();
}
constructor() {
this.db = new Database()
global.db = this.db
this.auth = new AuthHandler()
const app = express();
app.post("/webhook", express.raw({ type: "application/json" }), PaymentsHandler.webhook)
app.use(cors({ origin: '*' }));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(useragent.express());
app.use(this.logRequest);
app.use(this.logResponse);
let router = express.Router();
this.registerRoutes(router)
app.use('/', router);
const server = http.createServer(app);
global.Socket = new Socket(server);
const PORT = 10002;
server.listen(PORT, () => {
console.log("\n")
console.log(chalk.yellow("*************** parchment.page ********"))
console.log(chalk.yellowBright(`Server is running on port ${PORT}: http://localhost`));
console.log(chalk.yellow("***************************************"))
console.log("\n")
});
process.on('SIGINT', async () => {
console.log(chalk.red('Closing server...'));
console.log(chalk.green('Database connection closed.'));
process.exit(0);
});
Object.preventExtensions(this);
}
}
const server = new Server()

68
server/payments.js Normal file
View File

@@ -0,0 +1,68 @@
import Stripe from 'stripe';
import dotenv from 'dotenv';
dotenv.config();
const stripe = new Stripe(process.env.STRIPE_SECRET);
export default class PaymentsHandler {
static async danceTicket(req, res) {
try {
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
metadata: {
productId: "austin_winter_ball_2025_ticket"
},
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "Hyperia Winter Ball"
},
unit_amount: 3500
},
quantity: 1
}
],
success_url: `${process.env.PROTOCOL + process.env.BASE_URL}/success`,
cancel_url: `${process.env.PROTOCOL + process.env.BASE_URL}/events`
});
res.json({ url: session.url });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Something went wrong." });
}
}
static webhook = (req, res) => {
const sig = req.headers["stripe-signature"];
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.WEBHOOK_SECRET
);
if (event.type === "checkout.session.completed") {
const session = event.data.object;
let toStore = {
"product": session.metadata.productId,
"email": session.customer_details.email,
"name": session.customer_details.name,
"time": global.currentTime(),
"amount": session.amount_total,
}
global.db.payments.add(toStore)
}
res.sendStatus(200);
} catch (err) {
console.error(err);
res.sendStatus(400);
}
}
}

24
server/util.js Normal file
View File

@@ -0,0 +1,24 @@
global.ServerError = class extends Error {
constructor(status, msg) {
super(msg);
this.status = status;
}
}
global.currentTime = function () {
const now = new Date();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const year = now.getFullYear();
let hours = now.getHours();
const ampm = hours >= 12 ? "pm" : "am";
hours = hours % 12 || 12; // convert to 12-hour format
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
const ms = String(now.getMilliseconds()).padStart(4, "0"); // 4-digit like "5838"
return `${month}.${day}.${year}-${hours}:${minutes}:${seconds}${ms}${ampm}`;
}

View File

@@ -0,0 +1,43 @@
import { z } from 'zod';
const sendSchema = z.object({
forum: z.string(),
text: z.string(),
})
.strict()
const getSchema = z.object({
forum: z.string(),
number: z.number()
})
.strict()
export default class ForumHandler {
static handleSend(msg, ws) {
try {
global.db.posts.add(msg.text, msg.forum, ws.userEmail)
global.Socket.broadcast({event: "new-post", app: "FORUM", forum: msg.forum, msg: this.handleGet({forum: msg.forum, number: 100})})
return {success: true}
} catch(e) {
console.error(e)
}
}
static handleGet(msg) {
let data = global.db.posts.get(msg.forum, msg.number)
return data
}
static handle(operation, msg, ws) {
switch(operation) {
case "SEND":
if(!sendSchema.safeParse(msg).success) throw new Error("Incorrectly formatted Forum ws message!")
return this.handleSend(msg, ws)
case "GET":
if(!getSchema.safeParse(msg).success) throw new Error("Incorrectly formatted Forum ws message!")
return this.handleGet(msg)
}
}
}

View File

@@ -0,0 +1,40 @@
import { z } from 'zod';
const sendSchema = z.object({
conversation: z.string(),
text: z.string(),
})
.strict()
export default class MessagesHandler {
static handleSend(msg, ws) {
let user = global.db.members.getByEmail(ws.userEmail)
let convo = global.db.conversations.get(msg.conversation)
if(convo.between.includes(`MEMBER-${user.id}`)) {
global.db.messages.add(msg.conversation, msg.text, `MEMBER-${user.id}`)
global.Socket.broadcast({event: "new-message", app: "MESSAGES", msg: {conversationID: convo.id, messages: global.db.messages.getByConversation(`CONVERSATION-${msg.conversation}`)}})
} else {
throw new Error("Can't add to a conversation user is not a part of!")
}
return {success: true}
}
static handleGet(ws) {
let user = global.db.members.getByEmail(ws.userEmail)
let data = global.db.conversations.getByMember(`MEMBER-${user.id}`)
return data
}
static handle(operation, msg, ws) {
switch(operation) {
case "GET":
return this.handleGet(ws)
case "SEND":
if(!sendSchema.safeParse(msg).success) throw new Error("Incorrectly formatted Forum ws message!")
return this.handleSend(msg, ws)
}
}
}

109
server/ws/ws.js Normal file
View File

@@ -0,0 +1,109 @@
import { WebSocket, WebSocketServer } from 'ws';
import { z } from 'zod';
import jwt from 'jsonwebtoken';
import ForumHandler from "./handlers/ForumHandler.js"
import MessagesHandler from "./handlers/MessagesHandler.js"
export default class Socket {
wss;
messageSchema = z.object({
id: z.string(),
app: z.string(),
operation: z.string().optional(),
msg: z.union([
z.object({}).passthrough(), // allows any object
z.array(z.any()) // allows any array
]).optional()
})
.superRefine((data, ctx) => {
if (data.operation !== "GET" && data.msg === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["msg"],
message: "msg is required when operation is not GET"
})
}
})
.strict()
constructor(server) {
this.wss = new WebSocketServer({ server });
this.wss.on('connection', (ws, req) => {
console.log('✅ New WebSocket client connected');
function parseCookies(cookieHeader = "") {
return Object.fromEntries(
cookieHeader.split(";").map(c => {
const [key, ...v] = c.trim().split("=");
return [key, decodeURIComponent(v.join("="))];
})
);
}
const cookies = parseCookies(req.headers.cookie);
const token = cookies.auth_token;
if (!token) throw new Error("No auth token");
const payload = jwt.verify(token, process.env.JWT_SECRET);
ws.userEmail = payload.email;
ws.on('message', (msg) => {
this.handleMessage(msg, ws);
});
ws.on('close', () => {
console.log('Client disconnected');
});
});
console.log('WebSocket server initialized');
}
// Build a system where the ws obj is updated every time on navigate, so it already has context
// this way, we can only send broadcast messages to clients that actually have that app / subapp open
handleMessage = (msg, ws) => {
try {
const text = msg.toString();
const req = JSON.parse(text);
if(!this.messageSchema.safeParse(req).success) throw new Error("Socket.handleMessage: Incoming ws message has incorrect format!")
let responseData;
switch (req.app) {
case "FORUM":
responseData = ForumHandler.handle(req.operation, req.msg, ws)
break;
case "MESSAGES":
responseData = MessagesHandler.handle(req.operation, req.msg, ws)
break;
default:
console.error("unknown ws message")
}
let response = {
...req
}
response.msg = responseData
if(!this.messageSchema.safeParse(response).success) throw new Error("Socket.handleMessage: Outgoing ws message has incorrect format!")
ws.send(JSON.stringify(response))
} catch (e) {
console.error("Invalid WS message:", e);
}
}
broadcast(event) {
if (!this.wss) return;
let message = JSON.stringify(event)
this.wss.clients.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
});
}
}