initial commit
This commit is contained in:
commit
6f5a39c212
47 changed files with 3601 additions and 0 deletions
38
server/src/db.ts
Normal file
38
server/src/db.ts
Normal 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
85
server/src/index.ts
Normal 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
74
server/src/lib.ts
Normal 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
253
server/src/routes/auth.ts
Normal 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.",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
145
server/src/routes/profile.ts
Normal file
145
server/src/routes/profile.ts
Normal 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
277
server/src/routes/staff.ts
Normal 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
87
server/src/routes/ws.ts
Normal 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),
|
||||
});
|
||||
26
server/src/routes/ws/Client.ts
Normal file
26
server/src/routes/ws/Client.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
27
server/src/routes/ws/Participiant.ts
Normal file
27
server/src/routes/ws/Participiant.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
155
server/src/routes/ws/Room.ts
Normal file
155
server/src/routes/ws/Room.ts
Normal 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 };
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
160
server/src/routes/ws/Server.ts
Normal file
160
server/src/routes/ws/Server.ts
Normal 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
88
server/src/session.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue