initial commit

This commit is contained in:
Soph :3 2024-05-22 11:35:43 +03:00
commit 6f5a39c212
Signed by: sophie
GPG key ID: EDA5D222A0C270F2
47 changed files with 3601 additions and 0 deletions

46
server/.gitignore vendored Normal file
View file

@ -0,0 +1,46 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
**/*.trace
**/*.zip
**/*.tar.gz
**/*.tgz
**/*.log
package-lock.json
**/*.bun
static/
node_modules
.env

BIN
server/bun.lockb Executable file

Binary file not shown.

25
server/package.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "server",
"version": "1.0.50",
"type": "module",
"scripts": {
"dev": "bun run --watch src/index.ts"
},
"dependencies": {
"@elysiajs/cors": "^1.0.2",
"@elysiajs/eden": "^1.0.13",
"@elysiajs/static": "^1.0.3",
"@elysiajs/swagger": "^1.0.5",
"@lucia-auth/adapter-prisma": "^4.0.1",
"@prisma/client": "5.13.0",
"elysia": "^1.0.16",
"elysia-rate-limit": "^4.0.0",
"logestic": "^1.1.1",
"lucia": "^3.2.0",
"prisma": "^5.13.0"
},
"devDependencies": {
"bun-types": "latest"
},
"module": "src/index.js"
}

View file

@ -0,0 +1,43 @@
generator client {
provider = "prisma-client-js"
}
model Upload {
id String @id
expiresAt DateTime
type String
uploadedBy String
}
model User {
id String @id
username String
password String
staff Boolean @default(false)
status String @default("")
passkeys Json[]
sessions Session[]
}
model Punishment {
id String @id
type String // "mute", "ban"
reason String
punishedUserId String
staffId String
time Int
at DateTime
}
model Session {
id String @id
userId String
expiresAt DateTime
user User @relation(references: [id], fields: [userId], onDelete: Cascade)
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

38
server/src/db.ts Normal file
View file

@ -0,0 +1,38 @@
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import { PrismaClient } from "@prisma/client";
import { Lucia, TimeSpan } from "lucia";
export const client = new PrismaClient();
const adapter = new PrismaAdapter(client.session, client.user);
export const lucia = new Lucia(adapter, {
sessionExpiresIn: new TimeSpan(2, "w"), // 2 weeks
sessionCookie: {
attributes: {
secure: process.env.ENV === "PRODUCTION", // set `Secure` flag in HTTPS
},
},
getUserAttributes: (attributes) => {
return {
username: attributes.username,
password: attributes.password,
staff: attributes.staff,
status: attributes.status
};
},
});
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
username: string;
password: string;
status: string;
staff: boolean;
}

85
server/src/index.ts Normal file
View file

@ -0,0 +1,85 @@
/*
What if I never make it back from this damn panic attack?
GTB sliding in a Hellcat bumping From First to Last
When I die they're gonna make a park bench saying "This where he sat"
Me and Yung Sherman going rehab, this shit is very sad
Me and Yung Sherm in Venice Beach, man, this shit is very rad
Me and Yung Sherman at the gym working out and getting tanned
I never will see you again and I hope you understand
I'm crashing down some like a wave over castles made of sand
*/
// sad.ovh development
import cors from "@elysiajs/cors";
import Elysia from "elysia";
import auth from "./routes/auth";
import ws from "./routes/ws";
import profile from "./routes/profile";
import { Logestic } from "logestic";
import swagger from "@elysiajs/swagger";
import staticPlugin from "@elysiajs/static";
import { client } from "./db";
import staff from "./routes/staff";
import { mkdir } from "fs/promises";
try {
await mkdir("static/pfps", { recursive: true });
} catch {}
try {
await mkdir("static/images", { recursive: true });
} catch {}
const app = new Elysia()
.use(Logestic.preset("common"))
.use(
swagger({
documentation: {
tags: [
{
name: "Auth",
description: "All authenication routes have this tag.",
},
{ name: "Profile", description: "All profile routes have this tag." },
{
name: "Websocket",
description: "All websocket routes have this tag.",
},
{ name: "Staff", description: "All staff routes have this tag." },
{
name: "Authenication not required",
description: "Authenication isn't required",
},
],
},
})
)
.use(
cors({
origin: (context) => {
return /(http(|s):\/\/|)(sad\.ovh|127\.0\.0\.1|localhost)(:\d{1,4}|)(\/|)/gm.test(
context.url
); // TODO fix this
},
allowedHeaders: "Origin, X-Requested-With, Content-Type, Accept",
})
)
.use(
staticPlugin({
prefix: "",
assets: "static",
noCache: !(process.env.ENV === "PRODUCTION"),
})
)
.use(auth)
.use(ws)
.use(profile)
.use(staff)
.listen(+process.env.PORT!);
export type App = typeof app;
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

74
server/src/lib.ts Normal file
View file

@ -0,0 +1,74 @@
import { client } from "./db";
import { unlink, mkdir, exists } from "fs/promises";
export async function setDeleteTimeout(
entry: string | { id: string; expiresAt: Date; type: string }
) {
let upload: { id: string; expiresAt: Date; type: string };
if (typeof entry == "string") {
const x = await client.upload.findFirst({
where: {
id: entry,
},
});
if (!x) {
throw new Error(
"A delete timeout was set for the " + entry + ", which doesn't exist!"
);
}
upload = x;
} else {
upload = entry;
}
if (!(await exists("static/images/" + upload.id))) {
console.log(
"File with upload ID",
upload.id,
"was already deleted from FS, deleting it from DB."
);
await client.upload.delete({
where: {
id: upload.id,
},
});
return;
}
const diff = upload.expiresAt.getTime() - new Date().getTime();
if (diff < 0) {
console.log(
"File with upload ID",
upload.id,
"expired, which is being deleted."
);
await unlink("static/images/" + upload.id);
await client.upload.delete({
where: {
id: upload.id,
},
});
return;
}
console.log("File ID", upload.id, "expiring in", diff + "ms.");
setTimeout(async () => {
console.log(
"File with upload ID",
upload.id,
"reached expiration time, and it is being deleted"
);
await unlink("static/images/" + upload.id);
await client.upload.delete({
where: {
id: upload.id,
},
});
}, diff);
}
(await client.upload.findMany()).forEach(async (z) => {
await setDeleteTimeout(z);
});

253
server/src/routes/auth.ts Normal file
View file

@ -0,0 +1,253 @@
import { Elysia, t } from "elysia";
import { generateIdFromEntropySize, User } from "lucia";
import { client, lucia } from "../db";
import session, { ipGenerator } from "../session";
import { rateLimit } from "elysia-rate-limit";
async function checkRecaptcha(
recaptchaToken: string,
action: "login" | "register"
) {
const recaptchaTest = await fetch(
"https://www.google.com/recaptcha/api/siteverify?secret=" +
process.env.RECAPTCHA_SECRET +
"&response=" +
encodeURIComponent(recaptchaToken),
{
method: "POST",
}
);
const recaptchaJson = await recaptchaTest.json();
if (!recaptchaJson.success) {
return new Response("Recaptcha failed.", {
status: 400,
});
}
if (recaptchaJson.action != action) {
return new Response("Wrong action.", {
status: 400,
});
}
if (
recaptchaJson.hostname == "localhost" &&
process.env.ENV == "production"
) {
return new Response(
"Incorrect captcha, this is a production environment!",
{
status: 400,
}
);
}
if (recaptchaJson.score < 0.7) {
return new Response("Suspicious request!", {
status: 400,
});
}
}
export default new Elysia({
prefix: "/api/auth",
})
.use(session)
.get(
"/userInfo",
(context) => {
if (!context.user) {
return new Response(null, {
status: 401,
});
}
return {
username: context.user.username,
};
},
{
response: t.MaybeEmpty(
t.Object({
username: t.String(),
})
),
detail: {
tags: ["Auth"],
description: "Returns the username of the currently signed in user.",
},
}
)
.group("", (a) =>
a
.use(
rateLimit({
scoping: "local",
duration: 1 * 24 * 60 * 1000, // 1 day
max: 1,
generator: ipGenerator(),
})
)
.post(
"/register",
async (context) => {
context;
if (context.user as User) {
return new Response("You're already signed in!", {
status: 400,
});
}
const user = await client.user.findFirst({
where: {
username: context.body.username,
},
});
if (user) {
return new Response("User already exists!", {
status: 400,
});
}
if (context.body.username.length > 30) {
return new Response("Username greater than 30!", {
status: 400,
});
}
if (context.body.username.length < 3) {
return new Response("Username smaller than 3!", {
status: 400,
});
}
if (!/^[a-zA-Z0-9-_!.]*$/gm.test(context.body.username)) {
return new Response("Username is incorrectly formatted!", {
status: 400,
});
}
if (context.body.password.length < 5) {
return new Response("Password smaller than 5!", {
status: 400,
});
}
const recaptcha = await checkRecaptcha(
context.body.recaptcha,
"register"
);
if (recaptcha) return recaptcha;
const hashedPassword = await Bun.password.hash(context.body.password);
const id = generateIdFromEntropySize(10);
await client.user.create({
data: {
id,
username: context.body.username,
password: hashedPassword,
},
});
return true;
},
{
body: t.Object({
password: t.String(),
username: t.String(),
recaptcha: t.String(),
}),
detail: {
tags: ["Auth", "Authenication not required"],
description:
"Register endpoint. Requires a re-captcha token. This re-captcha token has to from our website.",
},
}
)
)
.get(
"/logout",
async (context) => {
if (!context.session) {
return new Response("You're not signed in.", {
status: 400,
});
}
await lucia.invalidateSession(context.session?.id);
const sessionCookie = lucia.createBlankSessionCookie();
context.cookie[sessionCookie.name].set(sessionCookie.attributes);
context.cookie[sessionCookie.name].value = sessionCookie.value;
return true;
},
{
detail: {
tags: ["Auth"],
description: "Logs out of the currently authenicated request.",
},
}
)
.group("", (a) =>
a
.use(
rateLimit({
scoping: "local",
duration: 60 * 1000, // 1 minute
max: 5, // 5 attempts
generator: ipGenerator(),
})
)
.post(
"/login",
async (context) => {
if (context.user) {
return new Response("You're already signed in!", {
status: 400,
});
}
const user = await client.user.findFirst({
where: {
username: context.body.username,
},
});
if (!user) {
return new Response("There is no user with this username.", {
status: 400,
});
}
if (
!(await Bun.password.verify(context.body.password, user.password))
) {
return new Response("Incorrect password.", {
status: 400,
});
}
const recaptcha = await checkRecaptcha(
context.body.recaptcha,
"login"
);
if (recaptcha) return recaptcha;
const session = await lucia.createSession(user.id, {});
const sessionCookie = lucia.createSessionCookie(session.id);
context.cookie[sessionCookie.name].set(sessionCookie.attributes);
context.cookie[sessionCookie.name].value = sessionCookie.value;
return true;
},
{
body: t.Object({
password: t.String(),
username: t.String(),
recaptcha: t.String(),
}),
detail: {
tags: ["Auth", "Authenication not required"],
description:
"Authenicates with password and username. Requires captcha, properly created.",
},
}
)
);

View file

@ -0,0 +1,145 @@
import { Elysia, t } from "elysia";
import session from "../session";
import { client } from "../db";
import { generateIdFromEntropySize } from "lucia";
import { setDeleteTimeout } from "../lib";
import { updateRooms } from "./ws";
export default new Elysia({
prefix: "/api/profile",
})
.use(session)
.post(
"/setStatus",
async (context) => {
if (!context.user) {
return new Response("Not authenicated", {
status: 401,
});
}
if (context.body.status.length > 40) {
return new Response("Status greater than 40!", {
status: 400,
});
}
if (context.body.status.length < 1) {
return new Response("Status smaller than 1!", {
status: 400,
});
}
await client.user.update({
where: {
id: context.user.id,
},
data: {
status: context.body.status,
},
});
updateRooms(context.user.id, "status", context.body.status);
},
{
body: t.Object({
status: t.String(),
}),
detail: {
tags: ["Profile"],
description: "Sets the status of the user. Can be seen in the client.",
},
}
)
.post(
"/setProfilePicture",
async (context) => {
if (!context.user) {
return new Response("Not authenicated", {
status: 401,
});
}
if (context.body.file.size > 2e7) {
return new Response("PFP too big", {
status: 400,
});
}
if (!context.body.file.type.startsWith("image/")) {
return new Response("PFP not image", {
status: 400,
});
}
const response = new Response(context.body.file.stream());
await Bun.write("static/pfps/" + context.user.id, response);
updateRooms(context.user.id, "pfp");
},
{
body: t.Object({
file: t.File(),
}),
detail: {
tags: ["Profile"],
description:
"Sets a profile picture. Max size 20MB, only images (mimetype image/).",
},
}
)
.post(
"/uploadImage",
async (context) => {
if (!context.user) {
return new Response("Not authenicated", {
status: 401,
});
}
if (context.body.file.size > 1e7) {
return new Response("Image too big", {
status: 400,
});
}
if (!context.body.file.type.startsWith("image/")) {
return new Response("File not image", {
status: 400,
});
}
const uploadId = generateIdFromEntropySize(10);
const expiresAt = new Date(Date.now() + 8.64e7);
await client.upload.create({
data: {
id: uploadId,
expiresAt,
type: context.body.file.type,
uploadedBy: context.user.id,
},
});
const response = new Response(context.body.file.stream());
await Bun.write("static/images/" + uploadId, response);
await setDeleteTimeout({
id: uploadId,
expiresAt,
type: context.body.file.type,
});
return {
uploadId: uploadId,
};
},
{
body: t.Object({
file: t.File(),
}),
response: t.MaybeEmpty(
t.Object({
uploadId: t.String(),
})
),
detail: {
tags: ["Profile"],
description:
"Uploads a file. Most commonly used for chat messages. These will only stay for 24 hours.",
},
}
);

277
server/src/routes/staff.ts Normal file
View file

@ -0,0 +1,277 @@
import Elysia, { t } from "elysia";
import session from "../session";
import { generateIdFromEntropySize } from "lucia";
import { client } from "../db";
import { disconnectID, findID } from "./ws";
export default new Elysia({
prefix: "/api/staff",
})
.use(session)
.post(
"/invalidatePunishment",
async (context) => {
if (!context.user) {
return new Response("Not authenicated", {
status: 401,
});
}
if (!context.user.staff) {
return new Response("You are not staff.", {
status: 401,
});
}
const punishment = await client.punishment.findFirst({
where: {
id: context.body.punishmentId,
},
});
if (!punishment) {
return new Response("No punishment found!", {
status: 401,
});
}
await client.punishment.delete({
where: {
id: context.body.punishmentId,
},
});
},
{
body: t.Object({
punishmentId: t.String(),
}),
detail: {
tags: ["Staff"],
description:
"Invalidates a request. Invalidate currently means delete, but it might in the future mean that the punishment is invalid.",
},
}
)
.post(
"/punishments",
async (context) => {
if (!context.user) {
return new Response("Not authenicated", {
status: 401,
});
}
if (!context.user.staff) {
return new Response("You are not staff.", {
status: 401,
});
}
const user = await client.user.findFirst({
where: {
id: context.body.userId,
},
});
if (!user) {
return new Response("User does not exist!", {
status: 401,
});
}
const punishments = await client.punishment.findMany({
where: {
punishedUserId: user.id,
},
});
return punishments.map((z) => {
return {
id: z.id,
type: z.type,
reason: z.reason,
staffId: z.staffId,
time: z.time,
at: z.at,
};
});
},
{
body: t.Object({
userId: t.String(),
}),
response: t.MaybeEmpty(
t.Array(
t.Object({
id: t.String(),
type: t.String(),
reason: t.String(),
staffId: t.String(),
time: t.Number(),
at: t.Date(),
})
)
),
detail: {
tags: ["Staff"],
description:
"View an user's punishments. Will show all punishments by user, and who banned them and for what reason. Can be invalidated.",
},
}
)
.guard(
{
body: t.Object({
id: t.String(),
reason: t.String(),
ms: t.Number(),
isPermanent: t.MaybeEmpty(t.Boolean()),
}),
detail: {
tags: ["Staff"],
description:
"Ban and Mute. These either ban, or mute a person. Mutes prohibit the user from chatting, and bans prohibit the user from joining.",
},
},
(a) =>
a
.post("/ban", async (context) => {
if (!context.user) {
return new Response("Not authenicated", {
status: 401,
});
}
if (!context.user.staff) {
return new Response("You are not staff.", {
status: 401,
});
}
const user = await client.user.findFirst({
where: {
id: context.body.id,
},
});
if (!user) {
return new Response("User does not exist!", {
status: 401,
});
}
if (user.staff) {
return new Response("Cannot ban staff!", {
status: 401,
});
}
disconnectID(user.id);
await client.punishment.create({
data: {
type: "ban",
id: generateIdFromEntropySize(10),
reason: context.body.reason,
punishedUserId: user.id,
staffId: context.user.id,
at: new Date(),
time: context.body.isPermanent ? -1 : context.body.ms,
},
});
})
.post("/mute", async (context) => {
if (!context.user) {
return new Response("Not authenicated", {
status: 401,
});
}
if (!context.user.staff) {
return new Response("You are not staff.", {
status: 401,
});
}
const user = await client.user.findFirst({
where: {
id: context.body.id,
},
});
if (!user) {
return new Response("User does not exist!", {
status: 401,
});
}
if (user.staff) {
return new Response("Cannot mute staff!", {
status: 401,
});
}
await client.punishment.create({
data: {
type: "mute",
id: generateIdFromEntropySize(10),
reason: context.body.reason,
punishedUserId: user.id,
staffId: context.user.id,
at: new Date(),
time: context.body.isPermanent ? -1 : context.body.ms,
},
});
})
)
.post(
"/isUserOnline",
async (context) => {
if (!context.user) {
return new Response("Not authenicated", {
status: 401,
});
}
if (!context.user.staff) {
return new Response("You are not staff.", {
status: 401,
});
}
const user = await client.user.findFirst({
where: {
id: context.body.userId,
},
});
if (!user) {
return new Response("User does not exist!", {
status: 401,
});
}
const participiants = findID(user.id);
if (participiants.length == 0) {
return {
isOnline: false,
name: user.username,
};
}
return {
isOnline: true,
rooms: participiants.map((z) => z.room),
name: user.username,
};
},
{
response: t.MaybeEmpty(
t.Object({
isOnline: t.Boolean(),
rooms: t.MaybeEmpty(t.Array(t.String())),
name: t.String(),
})
),
body: t.Object({
userId: t.String(),
}),
detail: {
tags: ["Staff"],
description:
"Returns username from ID, and checks if user is online. If is online, then also returns all rooms they're in.",
},
}
);

87
server/src/routes/ws.ts Normal file
View file

@ -0,0 +1,87 @@
/*
The Pulitzer Prize winner is definitely spiralin'
I got your fucking lines tapped, I swear that I'm dialed in
First, I was a rat, so where's the proof of the trial then?
Where's the paperwork or the cabinet it's filed in?
1090 Jake woulda took all the walls down
The streets woulda had me hidin' out in a small town
My Montreal connects stand up, now fall down
The ones that you're getting your stories from, they all clowns
I am a war general, sеasoned in preparation
My jacket is covеred in medals, honor and decoration
*/
// This file was developed independently from the rest of the project,
// as it's way more complicated than.. let's say the index.ts of the frontend.
// sad.ovh developmeant
import Elysia, {
InputSchema,
MergeSchema,
TSchema,
UnwrapRoute,
t,
} from "elysia";
import session, { ipGenerator } from "../session";
import { rateLimit } from "elysia-rate-limit";
import { ElysiaWSType, Server } from "./ws/Server";
import { Participiant } from "./ws/Participiant";
const server = new Server();
export function updateRooms(
userId: string,
type: "pfp" | "username" | "status",
data: string = ""
) {
server.rooms.forEach((z) => {
for (const [_, participiant] of z.participiants) {
if (participiant.user.id == userId) {
participiant.broadcast({
type: "updateUser",
updateType: type,
id: participiant.user.id,
data,
});
}
}
});
}
export function disconnectID(id: string) {
for (const room of server.rooms) {
for (const [_, part] of room.participiants) {
if (part.user.id == id) {
part.closeClients();
}
}
}
}
export function findID(id: string) {
const parts: Participiant[] = [];
for (const room of server.rooms) {
for (const [_, part] of room.participiants) {
if (part.user.id == id) {
parts.push(part);
}
}
}
return parts;
}
export default new Elysia()
.use(session)
.use(
rateLimit({
scoping: "local",
duration: 1000, // 1 second
max: 2,
generator: ipGenerator(),
})
)
.ws("/api/ws", {
open: (a) => server.open(a as unknown as ElysiaWSType),
message: (a, b) => server.message(a as unknown as ElysiaWSType, b),
close: (a) => server.close(a as unknown as ElysiaWSType),
});

View file

@ -0,0 +1,26 @@
import { Participiant } from "./Participiant";
import type { ElysiaWSType } from "./Server";
export class Client {
private ws: ElysiaWSType;
userId: string;
room?: string;
id: `${string}-${string}-${string}-${string}-${string}`;
constructor(ws: ElysiaWSType) {
this.ws = ws;
this.id = crypto.randomUUID();
this.userId = this.ws.data.user!.id;
}
makeParticipiant(): Participiant {
return new Participiant(this.ws.data.user!);
}
send(obj: Record<any, any>) {
this.ws.send(JSON.stringify(obj));
}
close() {
this.ws.close();
}
}

View file

@ -0,0 +1,27 @@
import type { User } from "lucia";
import { Client } from "./Client";
export class Participiant {
clients: Client[] = [];
room?: string;
user: User;
constructor(user: User) {
this.user = user;
}
closeClients() {
this.clients.forEach((z) => {
z.close();
});
}
addClient(client: Client) {
this.clients.push(client);
}
broadcast(obj: Record<any, any>) {
this.clients.forEach((z) => {
z.send(obj);
});
}
}

View file

@ -0,0 +1,155 @@
import { client } from "../../db";
import { Client } from "./Client";
import { Participiant } from "./Participiant";
import {exists} from "fs/promises";
export class Room {
participiants: Map<string, Participiant> = new Map();
name: string;
hidden: boolean;
messageHistory: {
userID: string;
content: string;
images?: string[] | undefined;
username: string;
time: number;
}[] = [];
timeout?: number;
constructor(name: string, hidden: boolean) {
this.name = name;
this.hidden = hidden;
}
async addMessage(
message: { message: string; images: string[] },
part: Participiant
) {
if (message.message.length > 200) return;
if (message.message.length < 1) return;
let validImages: string[] = [];
for (const image of message.images) {
if (!/^[a-z0-9]{1,16}$/gm.test(image)) continue;
if (await exists("static/images/" + image)) {
validImages.push(image);
}
}
const punishments = await client.punishment.findMany({
where: {
punishedUserId: part.user.id,
type: "mute",
},
});
for (const punishment of punishments) {
if (punishment.time == -1) punishment.time = Infinity;
const diff =
punishment.at.getTime() + punishment.time - new Date().getTime();
if (diff > 0) {
// punishment is valid
const punishedBy = await client.user.findFirst({
where: {
id: punishment.staffId,
},
});
part.broadcast({
type: "notification",
message: `You've been muted by ${punishedBy?.username}. There are ${diff}ms left in your mute. You were muted at ${punishment.at}. The reason you were muted is "${punishment.reason}"`,
});
return;
}
}
const msg = {
content: message.message,
images: validImages,
time: Date.now(),
userID: part.user.id,
username: part.user.username,
};
for (const part of this.participiants) {
part[1].broadcast({
type: "message",
message: msg,
});
}
this.messageHistory.push(msg);
}
addClient(client: Client) {
let part = this.participiants.get(client.userId);
if (part) {
part.room = this.name;
part.addClient(client);
} else {
part = client.makeParticipiant();
part.room = this.name;
part.addClient(client);
this.participiants.set(part.user.id, part);
}
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = undefined;
client.send({
type: "notification",
message: `You saved ${this.name}! Thank you!`,
});
}
client.send({
type: "history",
messages: this.messageHistory,
});
client.send({
type: "room",
room: this.name,
});
this.updatePeople();
}
removeClient(client: Client, timeoutFunction: () => void) {
let part = this.participiants.get(client.userId);
if (!part) return;
part.clients = part.clients.filter((z) => z.id !== client.id);
if (part.clients.length == 0) {
this.participiants.delete(client.userId);
}
if (this.participiants.size == 0) {
client.send({
type: "notification",
message:
this.name +
" has 0 users left! If nobody joins in 15 seconds, the room's history will be deleted.",
});
this.timeout = setTimeout(timeoutFunction, 15000) as unknown as number;
}
this.updatePeople();
return part.clients.length == 0;
}
sendPeople(z: Client) {
z.send({
type: "people",
people: [...this.participiants.values()].map((z) => {
return { username: z.user?.username, id: z.user?.id, status: z.user?.status };
}),
});
}
updatePeople() {
this.participiants.forEach((z) => {
z.broadcast({
type: "people",
people: [...this.participiants.values()].map((z) => {
return { username: z.user?.username, id: z.user?.id, status: z.user?.status };
}),
});
});
}
}

View file

@ -0,0 +1,160 @@
import { ServerWebSocket } from "bun";
import type { TSchema, MergeSchema, UnwrapRoute, InputSchema } from "elysia";
import type { TypeCheck } from "elysia/dist/type-system";
import type { ElysiaWS } from "elysia/dist/ws";
import type { User, Session } from "lucia";
import { client } from "../../db";
import { Room } from "./Room";
import { Client } from "./Client";
export type ElysiaWSType = ElysiaWS<
ServerWebSocket<{
validator?: TypeCheck<TSchema> | undefined;
}>,
MergeSchema<UnwrapRoute<InputSchema<never>, {}>, {}> & {
params: Record<never, string>;
},
{
decorator: {};
store: {};
derive: {};
resolve: {
client: Client | null;
user: User | null;
session: Session | null;
};
} & {
derive: {};
resolve: {};
}
>;
export class Server {
clients: Client[] = [];
rooms: Room[] = [];
async open(ws: ElysiaWSType) {
if (!ws.data.user) {
ws.close();
return;
}
const punishments = await client.punishment.findMany({
where: {
punishedUserId: ws.data.user.id,
type: "ban",
},
});
for (const punishment of punishments) {
if (punishment.time == -1) punishment.time = Infinity;
const diff =
punishment.at.getTime() + punishment.time - new Date().getTime();
if (diff > 0) {
// punishment is valid
const punishedBy = await client.user.findFirst({
where: {
id: punishment.staffId,
},
});
ws.send(
JSON.stringify({
type: "notification",
message: `You've been banned by ${punishedBy?.username}. There are ${diff}ms left in your ban. You were banned at ${punishment.at}. The reason you were banned is "${punishment.reason}"`,
})
);
ws.send(
JSON.stringify({
type: "notification",
message: `Your ID: ${punishment.punishedUserId}, punishment ID: ${punishment.id}`,
})
);
ws.close();
return;
}
}
const ccl = new Client(ws);
this.clients.push(ccl);
ws.data.client = ccl;
ccl.send({
type: "hello",
staff: ws.data.user.staff,
});
}
message(ws: ElysiaWSType, message: any) {
if (!ws.data.client) return;
const client = ws.data.client;
if (message.type == "room") {
if (
!message.room ||
typeof message.room != "string" ||
message.room.length == 0 ||
message.room.length > 20
)
message.room = "lobby";
if (client.room) {
if (client.room == message.room) return;
const prevRoom = this.rooms.find((z) => z.name == client.room);
if (prevRoom)
prevRoom.removeClient(client, () => {
this.rooms = this.rooms.filter((z) => z.name !== prevRoom.name);
this.updateRooms();
});
}
let room = this.rooms.find((z) => z.name == message.room);
if (!room) {
room = new Room(message.room, !!message.hidden);
this.rooms.push(room);
}
room.addClient(client);
client.room = message.room;
this.updateRooms();
}
if (message.type == "message") {
const room = this.rooms.find((z) => z.name == client.room);
if (!room) return;
room.addMessage(message, room.participiants.get(client.userId)!);
}
}
close(ws: ElysiaWSType) {
if (!ws.data.client) return;
this.clients = this.clients.filter((z) => z.id !== ws.data.client?.id);
const room = this.rooms.find((z) => z.name == ws.data.client?.room);
if (room) {
if (room.removeClient(ws.data.client!, () => {
this.rooms = this.rooms.filter((z) => z.name !== room.name);
this.updateRooms();
})) {
this.updateRooms();
}
}
}
private sendRooms(client: Client) {
client.send({
type: "rooms",
rooms: this.rooms
.filter((z) => !z.hidden)
.map((g) => {
return { name: g.name, count: g.participiants.size };
}),
});
}
updateRooms() {
this.clients.forEach((z) => this.sendRooms(z));
}
}

88
server/src/session.ts Normal file
View file

@ -0,0 +1,88 @@
import Elysia from "elysia";
import { User, Session, verifyRequestOrigin } from "lucia";
import { lucia } from "./db";
import type { Generator } from "elysia-rate-limit";
export const ipGenerator = () => {
let gen: Generator<any>;
if (process.env.DETECT_IP == "fwf") {
gen = (req, server) => {
let fwf = req.headers.get("x-forwarded-for")!;
if (!fwf) {
console.log(
"!!! x-forwarded-for missing on request, while DETECT_IP is set to fwf! falling back to server IP !!!"
);
fwf = server?.requestIP(req)?.address!;
}
return fwf;
};
} else {
gen = (req, server) => {
return server?.requestIP(req)?.address!;
};
}
return gen;
};
export default new Elysia({
name: "session",
}).derive(
{ as: "global" },
async (
context
): Promise<{
user: User | null;
session: Session | null;
}> => {
// CSRF check
if (context.request.method !== "GET") {
const originHeader = context.request.headers.get("Origin");
// NOTE: You may need to use `X-Forwarded-Host` instead
const hostHeader = context.request.headers.get("Host");
if (
!originHeader ||
!hostHeader ||
!verifyRequestOrigin(originHeader, [hostHeader, "localhost:5173"])
) {
return {
user: null,
session: null,
};
}
}
// use headers instead of Cookie API to prevent type coercion
const cookieHeader = context.request.headers.get("Cookie") ?? "";
const sessionId = lucia.readSessionCookie(cookieHeader);
if (!sessionId) {
return {
user: null,
session: null,
};
}
const { session, user } = await lucia.validateSession(sessionId);
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id);
context.cookie[sessionCookie.name].set({
value: sessionCookie.value,
...sessionCookie.attributes,
});
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie();
context.cookie[sessionCookie.name].set({
value: sessionCookie.value,
...sessionCookie.attributes,
});
}
return {
user,
session,
};
}
);

103
server/tsconfig.json Normal file
View file

@ -0,0 +1,103 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
"noErrorTruncation": true,
/* Language and Environment */
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2022", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}