first commit
This commit is contained in:
46
backend/src/index.ts
Normal file
46
backend/src/index.ts
Normal 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
71
backend/src/lib/Auth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
108
backend/src/routes/account/api-keys.ts
Normal file
108
backend/src/routes/account/api-keys.ts
Normal 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 });
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
29
backend/src/routes/account/delete.ts
Normal file
29
backend/src/routes/account/delete.ts
Normal 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" });
|
||||
})
|
||||
);
|
||||
56
backend/src/routes/account/download.ts
Normal file
56
backend/src/routes/account/download.ts
Normal 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));
|
||||
})
|
||||
);
|
||||
28
backend/src/routes/account/fetch.ts
Normal file
28
backend/src/routes/account/fetch.ts
Normal 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
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
58
backend/src/routes/account/login.ts
Normal file
58
backend/src/routes/account/login.ts
Normal 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 });
|
||||
})
|
||||
);
|
||||
30
backend/src/routes/account/logout.ts
Normal file
30
backend/src/routes/account/logout.ts
Normal 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" });
|
||||
})
|
||||
);
|
||||
60
backend/src/routes/account/manage.ts
Normal file
60
backend/src/routes/account/manage.ts
Normal 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" });
|
||||
})
|
||||
);
|
||||
48
backend/src/routes/account/profile.ts
Normal file
48
backend/src/routes/account/profile.ts
Normal 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" });
|
||||
})
|
||||
);
|
||||
65
backend/src/routes/account/register.ts
Normal file
65
backend/src/routes/account/register.ts
Normal 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 });
|
||||
})
|
||||
);
|
||||
43
backend/src/routes/account/subject-colors.ts
Normal file
43
backend/src/routes/account/subject-colors.ts
Normal 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" });
|
||||
})
|
||||
);
|
||||
60
backend/src/routes/exams/bulk-create.ts
Normal file
60
backend/src/routes/exams/bulk-create.ts
Normal 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
|
||||
});
|
||||
})
|
||||
);
|
||||
69
backend/src/routes/exams/create.ts
Normal file
69
backend/src/routes/exams/create.ts
Normal 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
|
||||
});
|
||||
})
|
||||
);
|
||||
32
backend/src/routes/exams/delete.ts
Normal file
32
backend/src/routes/exams/delete.ts
Normal 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"
|
||||
});
|
||||
})
|
||||
);
|
||||
25
backend/src/routes/exams/get.ts
Normal file
25
backend/src/routes/exams/get.ts
Normal 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 });
|
||||
})
|
||||
);
|
||||
26
backend/src/routes/exams/list.ts
Normal file
26
backend/src/routes/exams/list.ts
Normal 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
|
||||
});
|
||||
})
|
||||
);
|
||||
51
backend/src/routes/exams/update.ts
Normal file
51
backend/src/routes/exams/update.ts
Normal 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
41
backend/src/types.ts
Normal 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 };
|
||||
Reference in New Issue
Block a user