first commit

This commit is contained in:
Space-Banane
2026-02-09 19:26:40 +01:00
commit ead561d10b
42 changed files with 2364 additions and 0 deletions

46
backend/src/index.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Server } from "rjweb-server";
import { Runtime } from "@rjweb/runtime-node";
import * as dotenv from "dotenv";
import * as mongoDB from "mongodb";
import { env } from "node:process";
dotenv.config({ path: "../.env" });
export const config = {
CookieDomain: env.COOKIE_DOMAIN || "localhost"
}
const client: mongoDB.MongoClient = new mongoDB.MongoClient(
env.DB_CONN_STRING!
);
const db: mongoDB.Db = client.db("exams_db");
const COOKIENAME = "exams_session";
export { db, client, COOKIENAME };
const server = new Server(Runtime, {
port: 8080,
});
export const fileRouter = new server.FileLoader("/")
.load("./routes", { fileBasedRouting: false })
.export();
server.path("/", (path) =>
path.static("../../frontend/dist", {
stripHtmlEnding: true,
})
);
server.notFound(async (ctr) => {
return ctr
.status(200, "maybe frontend?")
.printFile("../../frontend/dist/index.html", { addTypes: true });
});
server.start().then(async (port) => {
await client.connect();
console.log(`Server started on port ${port}!`);
});

71
backend/src/lib/Auth.ts Normal file
View File

@@ -0,0 +1,71 @@
import { db } from "..";
import { Session, User } from "../types";
import crypto from "crypto";
interface AuthCheckResultBase {
state: boolean;
message: string;
}
interface AuthCheckSuccess extends AuthCheckResultBase {
state: true;
userId: string;
user: User;
}
interface AuthCheckFailure extends AuthCheckResultBase {
state: false;
}
type AuthCheckResult = AuthCheckSuccess | AuthCheckFailure;
export async function authCheck(
cookie: string | null,
apiHeader: string | null = null
): Promise<AuthCheckResult> {
// If API header provided, try API key auth first
if (apiHeader) {
try {
const hash = crypto.createHash("sha256").update(apiHeader).digest("hex");
const userByKey = await db
.collection<User>("users")
.findOne({ "apiKeys.keyHash": hash });
if (userByKey) {
// find the apiKey id
const found = (userByKey.apiKeys || []).find((k) => k.keyHash === hash);
return {
state: true,
message: "Authenticated via API key",
userId: userByKey.id,
user: userByKey,
};
}
} catch (e) {
// fall through to session-based auth
}
}
if (!cookie) {
return { state: false, message: "No Cookie Provided" };
}
const session = await db.collection<Session>("sessions").findOne({ token: cookie });
if (!session) {
return { state: false, message: "Invalid Session" };
}
if (session.expiresAt < new Date()) {
return { state: false, message: "Session Expired" };
}
const user = await db.collection<User>("users").findOne({ id: session.userId });
if (!user) {
return { state: false, message: "User Not Found" };
}
return {
state: true,
message: "Authenticated",
userId: session.userId,
user: user,
};
}

View File

@@ -0,0 +1,108 @@
import { COOKIENAME } from "../..";
import { db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
import crypto from "crypto";
export = new fileRouter.Path("/")
.http("GET", "/api/account/api-keys", (http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
const user = await db.collection("users").findOne({ id: auth.userId });
if (!user) {
return ctr.status(ctr.$status.NOT_FOUND).print({ error: "User not found" });
}
const keys = (user.apiKeys || []).map((k: any) => ({
id: k.id,
description: k.description,
createdAt: k.createdAt,
}));
return ctr.print({ success: true, apiKeys: keys });
})
)
.http("POST", "/api/account/api-keys/create", (http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({ description: z.string().min(1).max(200) })
);
if (!data) return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
const user = await db.collection("users").findOne({ id: auth.userId });
if (!user) return ctr.status(ctr.$status.NOT_FOUND).print({ error: "User not found" });
const existing = user.apiKeys || [];
if (existing.length >= 4) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "API key limit reached (max 4)" });
}
const raw = crypto.randomBytes(32).toString("hex");
const keyHash = crypto.createHash("sha256").update(raw).digest("hex");
const keyId = crypto.randomUUID();
const newKey = {
id: keyId,
description: data.description,
keyHash,
createdAt: new Date(),
};
const res = await db.collection("users").updateOne(
{ id: auth.userId },
{ $push: { apiKeys: newKey } } as any,
{ upsert: false }
);
if (!res.acknowledged) {
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ error: "Failed to create API key" });
}
return ctr.print({ success: true, apiKey: { id: keyId, description: data.description, createdAt: newKey.createdAt, token: raw } });
})
)
.http("DELETE", "/api/account/api-keys/{id}", (http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
const keyId = ctr.params.get("id");
if (!keyId) return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "Key id required" });
const res = await db.collection("users").updateOne(
{ id: auth.userId },
{ $pull: { apiKeys: { id: keyId } } } as any
);
if (!res.acknowledged) {
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({ error: "Failed to delete API key" });
}
return ctr.print({ success: true });
})
);

View File

@@ -0,0 +1,29 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
import bcrypt from "bcryptjs";
export = new fileRouter.Path("/").http(
"DELETE",
"/api/account/delete",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = ctr.headers?.get?.("api-authentication") || null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
// Delete all exams for the user
await db.collection("exams").deleteMany({ userId: auth.userId });
// Delete the user account
await db.collection("users").deleteOne({ id: auth.userId });
// Clear session cookie
ctr.cookies.delete(COOKIENAME);
return ctr.print({ success: true, message: "Account deleted" });
})
);

View File

@@ -0,0 +1,56 @@
import { COOKIENAME } from "../..";
import { db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
import { Exam } from "../../types";
export = new fileRouter.Path("/").http(
"GET",
"/api/account/download",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
// Fetch all user data
const exams = await db
.collection<Exam>("exams")
.find({ userId: auth.userId })
.toArray();
const userData = {
user: {
id: auth.user.id,
username: auth.user.username,
},
exams: exams.map((exam:Exam) => ({
id: exam.id,
name: exam.name,
description: exam.description,
date: exam.date,
result: exam.result,
imageUrls: exam.imageUrls,
createdAt: exam.createdAt,
updatedAt: exam.updatedAt,
})),
exportedAt: new Date().toISOString(),
};
// Set headers for file download
ctr.headers.set("Content-Type", "application/json");
ctr.headers.set(
"Content-Disposition",
`attachment; filename="exams-data-${auth.user.username}-${Date.now()}.json"`
);
return ctr.print(JSON.stringify(userData, null, 2));
})
);

View File

@@ -0,0 +1,28 @@
import { COOKIENAME } from "../..";
import { db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http("GET", "/api/account/fetch", (http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
// Return user data without sensitive information
return ctr.print({
success: true,
user: {
...auth.user,
passwordHash: undefined, // nukes sensitive info
},
});
})
);

View File

@@ -0,0 +1,58 @@
import { Cookie } from "rjweb-server";
import { db, fileRouter, config } from "../../";
import bcrypt from "bcryptjs";
import { hash } from "crypto";
import { COOKIENAME } from "../..";
export = new fileRouter.Path("/").http(
"POST",
"/api/account/login",
(http) =>
http.onRequest(async (ctr) => {
const [data, error] = await ctr.bindBody((z) =>
z.object({
username: z.string().min(3).max(20),
password: z.string().min(6).max(200),
})
);
if (!data)
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
// Find user
const user = await db.collection("users").findOne({
username: data.username,
});
if (!user) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: "Invalid username or password" });
}
// Verify password
const isPasswordValid = await bcrypt.compare(data.password, user.passwordHash);
if (!isPasswordValid) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: "Invalid username or password" });
}
// Create Session
const token = hash("sha256", crypto.randomUUID() + data.username, "hex");
await db.collection("sessions").insertOne({
id: crypto.randomUUID(),
userId: user.id,
token,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days
});
ctr.cookies.set(
COOKIENAME,
new Cookie(token, {
domain: config.CookieDomain,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days
})
);
return ctr.print({ success: true });
})
);

View File

@@ -0,0 +1,30 @@
import { COOKIENAME } from "../..";
import { db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"POST",
"/api/account/logout",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
// Delete the session from the database
await db.collection("sessions").deleteOne({ token: cookie });
// Clear the cookie
ctr.cookies.delete(COOKIENAME);
return ctr.print({ success: true, message: "Logged out successfully" });
})
);

View File

@@ -0,0 +1,60 @@
import { COOKIENAME } from "../..";
import { db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
import bcrypt from "bcryptjs";
export = new fileRouter.Path("/").http(
"POST",
"/api/account/manage",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(6).max(200),
})
);
if (!data)
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
// Verify current password
const passwordMatch = await bcrypt.compare(
data.currentPassword,
auth.user.passwordHash
);
if (!passwordMatch) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: "Current password is incorrect" });
}
// Hash new password and update
const newPasswordHash = await bcrypt.hash(data.newPassword, 10);
const result = await db.collection("users").updateOne(
{ id: auth.userId },
{ $set: { passwordHash: newPasswordHash } }
);
if (result.modifiedCount === 0) {
return ctr
.status(ctr.$status.INTERNAL_SERVER_ERROR)
.print({ error: "Failed to update password" });
}
return ctr.print({ success: true, message: "Password updated successfully" });
})
);

View File

@@ -0,0 +1,48 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"POST",
"/api/account/profile",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({
displayName: z.string().max(100).optional(),
iconUrl: z.string().url().max(500).or(z.literal("")).optional(),
})
);
if (!data)
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const updateData: any = {};
if (data.displayName !== undefined) updateData.displayName = data.displayName;
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl === "" ? null : data.iconUrl;
const result = await db.collection("users").updateOne(
{ id: auth.userId },
{ $set: updateData }
);
if (result.matchedCount === 0) {
return ctr
.status(ctr.$status.INTERNAL_SERVER_ERROR)
.print({ error: "Failed to update profile" });
}
return ctr.print({ success: true, message: "Profile updated successfully" });
})
);

View File

@@ -0,0 +1,65 @@
import { Cookie } from "rjweb-server";
import { db, fileRouter, config } from "../../";
import bcrypt from "bcryptjs";
import { hash } from "crypto";
import { COOKIENAME } from "../..";
export = new fileRouter.Path("/").http(
"POST",
"/api/account/register",
(http) =>
http.onRequest(async (ctr) => {
const [data, error] = await ctr.bindBody((z) =>
z.object({
username: z.string().min(3).max(20),
password: z.string().min(6).max(200),
})
);
if (!data)
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
// Check if username is taken
const existingUser = await db.collection("users").findOne({
username: data.username,
});
if (existingUser) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: "Username already taken" });
}
// Create user
const id = crypto.randomUUID();
const res = await db.collection("users").insertOne({
id,
username: data.username,
passwordHash: await bcrypt.hash(data.password, 10),
createdAt: new Date(),
});
if (res.acknowledged === false) {
return ctr.status(500).print({ error: "Failed to set the value" });
}
// Create Session
const token = hash("sha256", crypto.randomUUID() + data.username, "hex");
await db.collection("sessions").insertOne({
id: crypto.randomUUID(),
userId: id,
token,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days
});
ctr.cookies.set(
COOKIENAME,
new Cookie(token, {
domain: config.CookieDomain,
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 90), // 90 days
})
);
return ctr.print({ success: true });
})
);

View File

@@ -0,0 +1,43 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"POST",
"/api/account/subject-colors",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr
.status(ctr.$status.UNAUTHORIZED)
.print({ error: auth.message });
}
const [data, error] = await ctr.bindBody((z) =>
z.object({
colors: z.record(z.string(), z.string().regex(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/)),
})
);
if (!data)
return ctr.status(ctr.$status.BAD_REQUEST).print(error.toString());
const result = await db.collection("users").updateOne(
{ id: auth.userId },
{ $set: { subjectColors: data.colors } }
);
if (result.matchedCount === 0) {
return ctr
.status(ctr.$status.INTERNAL_SERVER_ERROR)
.print({ error: "Failed to update subject colors" });
}
return ctr.print({ success: true, message: "Subject colors updated successfully" });
})
);

View File

@@ -0,0 +1,60 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"POST",
"/api/exams/bulk-create",
(http) =>
http.onRequest(async (ctr) => {
// Check authentication
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
// Validate request body
const [data, error] = await ctr.bindBody((z) =>
z.object({
exams: z.array(z.object({
name: z.string().min(1).max(200),
subject: z.string().min(1).max(200),
duration: z.number().min(0).optional(),
description: z.string().max(5000).optional(),
date: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: "Invalid date format",
})
}))
})
);
if (!data) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
}
const now = new Date();
const createdExams = data.exams.map((exam) => ({
id: crypto.randomUUID(),
name: exam.name,
description: exam.description || "",
date: new Date(exam.date),
result: "",
imageUrls: [],
userId: auth.userId,
createdAt: now,
updatedAt: now,
subject: exam.subject,
duration: exam.duration || 0,
}));
await db.collection("exams").insertMany(createdExams);
return ctr.status(ctr.$status.CREATED).print({
success: true,
count: createdExams.length
});
})
);

View File

@@ -0,0 +1,69 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"POST",
"/api/exams/create",
(http) =>
http.onRequest(async (ctr) => {
// Check authentication
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
// Validate request body
const [data, error] = await ctr.bindBody((z) =>
z.object({
name: z.string().min(1).max(200),
subject: z.string().min(1).max(200),
duration: z.number().min(0),
description: z.string().max(5000).optional(),
date: z.string().refine((val) => !isNaN(Date.parse(val)), {
message: "Invalid date format",
}),
result: z.string().min(1).max(100).optional(),
imageUrls: z.array(z.string().url()).optional(),
})
);
if (!data) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
}
// Create the idea
const now = new Date();
const id = crypto.randomUUID();
const exam = {
id,
name: data.name,
description: data.description,
date: new Date(data.date),
result: data.result || "",
imageUrls: data.imageUrls || [],
userId: auth.userId,
createdAt: now,
updatedAt: now,
subject: data.subject,
duration: data.duration,
};
const res = await db.collection("exams").insertOne(exam);
if (!res.acknowledged) {
return ctr.status(ctr.$status.INTERNAL_SERVER_ERROR).print({
error: "Failed to create exam"
});
}
return ctr.print({
success: true,
exam
});
})
);

View File

@@ -0,0 +1,32 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"DELETE",
"/api/exams/delete/{id}",
(http) =>
http.onRequest(async (ctr) => {
// Check authentication
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = ctr.headers?.get?.("api-authentication") || null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
// Get exam ID from request parameters
const id = ctr.params.get("id");
// Delete the exam
const res = await db.collection("exams").deleteOne({ id, userId: auth.userId });
if (res.deletedCount === 0) {
return ctr.status(ctr.$status.NOT_FOUND).print({ error: "Exam not found" });
}
return ctr.print({
success: true,
message: "Exam deleted successfully"
});
})
);

View File

@@ -0,0 +1,25 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"GET",
"/api/exams/get/{id}",
(http) =>
http.onRequest(async (ctr) => {
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = ctr.headers?.get?.("api-authentication") || null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
const id = ctr.params.get("id");
const exam = await db.collection("exams").findOne({ id, userId: auth.userId });
if (!exam) {
return ctr.status(ctr.$status.NOT_FOUND).print({ error: "Exam not found" });
}
return ctr.print({ success: true, exam });
})
);

View File

@@ -0,0 +1,26 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"GET",
"/api/exams/list",
(http) =>
http.onRequest(async (ctr) => {
// Check authentication
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = (ctr.headers && ctr.headers.get)
? ctr.headers.get("api-authentication") || null
: null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
// Get all exams for the authenticated user
const exams = await db.collection("exams").find({ userId: auth.userId }).toArray();
return ctr.print({
success: true,
exams
});
})
);

View File

@@ -0,0 +1,51 @@
import { COOKIENAME, db, fileRouter } from "../../";
import { authCheck } from "../../lib/Auth";
export = new fileRouter.Path("/").http(
"POST",
"/api/exams/update/",
(http) =>
http.onRequest(async (ctr) => {
// Check authentication
const cookie = ctr.cookies.get(COOKIENAME) || null;
const apiHeader = ctr.headers?.get?.("api-authentication") || null;
const auth = await authCheck(cookie, apiHeader);
if (!auth.state) {
return ctr.status(ctr.$status.UNAUTHORIZED).print({ error: auth.message });
}
// Validate request body
const [data, error] = await ctr.bindBody((z) =>
z.object({
id: z.string().min(1),
subject: z.string().min(1).max(200),
duration: z.number().min(0).optional(),
name: z.string().min(1).max(200).optional(),
description: z.string().min(1).max(5000).optional(),
date: z.string().refine((val) => !isNaN(Date.parse(val))).optional(),
result: z.string().min(1).max(100).optional(),
imageUrls: z.array(z.string().url()).optional(),
})
);
if (!data) {
return ctr.status(ctr.$status.BAD_REQUEST).print({ error: error.toString() });
}
// Build update object
const updateData: any = { ...data, updatedAt: new Date() };
if (data.date) updateData.date = new Date(data.date);
// Update the exam
const res = await db.collection("exams").updateOne(
{ id: data.id, userId: auth.userId },
{ $set: updateData }
);
if (res.matchedCount === 0) {
return ctr.status(ctr.$status.NOT_FOUND).print({ error: "Exam not found" });
}
return ctr.print({ success: true });
})
);

41
backend/src/types.ts Normal file
View File

@@ -0,0 +1,41 @@
interface Session {
token: string;
userId: string;
expiresAt: Date;
}
interface User {
id: string;
username: string;
displayName?: string;
iconUrl?: string;
passwordHash: string;
apiKeys?: ApiKey[];
subjectColors?: Record<string, string>;
}
interface ApiKey {
id: string;
description: string;
keyHash: string; // sha256 of the raw key
createdAt: Date;
}
interface Exam {
id: string;
name: string;
date: Date;
subject: string; // New field for the subject of the exam
duration: number; // Duration of the exam in minutes
result: string; // Custom defineable result format, e.g. "A", "B", "C", "Pass", "Fail"
userId: string; // Reference to the owning user
description?: string; // Optional field for additional details about the exam
imageUrls?: string[]; // Optional array of image URLs related to the exam (e.g. scanned results, certificates)
createdAt: Date;
updatedAt: Date;
}
export { Session, User, ApiKey, Exam };