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

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