commit ead561d10b70d2ffc414ba6d1e8f0e59fb7e1202 Author: Space-Banane Date: Mon Feb 9 19:26:40 2026 +0100 first commit diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8693f03 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,44 @@ +# Copilot Instructions for Exam Manager + +## Architecture Overview +- **Backend**: TypeScript (Node.js) located in [/backend](/backend). Uses `rjweb-server` for the web server and `mongodb` for the database. +- **Frontend**: React (Vite) located in [/frontend](/frontend). Uses Tailwind CSS for styling and React Router for navigation. +- **Database**: MongoDB. Database connection string is managed via `.env` file. +- **Deployment**: Backend serves the frontend's built files from `/frontend/dist`. API routes are prefixed with `/api`. + +## Core Patterns & Conventions + +### Backend (Node.js/TypeScript) +- **Server Framework**: `rjweb-server`. Routes are defined using `fileRouter.Path`. +- **Database Access**: Use the exported `db` object from [backend/src/index.ts](backend/src/index.ts). +- **Models & Types**: Defined in [backend/src/types.ts](backend/src/types.ts). +- **Authentication**: Session-based using a cookie named `exams_session` or an `api-authentication` header. Use `authCheck` from [backend/src/lib/Auth.ts](backend/src/lib/Auth.ts) to protect routes. +- **Static Files**: The backend serves the frontend's built files from `/frontend/dist`. API routes are prefixed with `/api`. + +### Frontend (React) +- **API Calls**: Axios-based. Authentication is handled automatically via cookies. API paths start with `/api/`. +- **Types**: Always keep [frontend/src/types.ts](frontend/src/types.ts) in sync with backend types. +- **Routing**: Managed in [frontend/src/App.tsx](frontend/src/App.tsx). Protected routes should verify authentication with the backend. +- **Icons**: Use `lucide-react` for iconography. + +## Developer Workflows +- **Running with Docker**: Use `docker-compose up --build` to build both stacks and the DB. +- **Local Backend Dev**: + ```bash + cd backend + npm install + npm run dev + ``` +- **Local Frontend Dev**: + ```bash + cd frontend + npm install + npm run dev + ``` + +## Important Files +- [backend/src/index.ts](backend/src/index.ts): Entry point and server configuration. +- [backend/src/routes/](backend/src/routes/): Directory containing all API endpoints. +- [backend/src/types.ts](backend/src/types.ts): Shared TypeScript interfaces for models. +- [frontend/src/App.tsx](frontend/src/App.tsx): Frontend routing and auth state. +- [frontend/src/types.ts](frontend/src/types.ts): Type definitions for the frontend. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8bd1f56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +./db +__pycache__/ +*.pyc +*.pyo + +# Ignore Secrets +.env + +# Node +node_modules/ +dist/ +package-lock.json +pnpm-lock.yaml +yarn.lock +.pnpm-store/ \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..1491899 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,19 @@ +{ + "dependencies": { + "@rjweb/runtime-node": "^1.1.1", + "@rjweb/utils": "^1.12.29", + "@types/node": "^25.0.3", + "bcryptjs": "^3.0.3", + "dotenv": "^17.2.3", + "mongodb": "^7.0.0", + "rimraf": "^6.1.2", + "rjweb-server": "^9.8.6", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + }, + "scripts": { + "build": "rimraf dist && tsc", + "dev": "npm run build && cd dist && node index.js", + "start": "npm run build && cd dist && node index.js" + } +} diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..976604a --- /dev/null +++ b/backend/src/index.ts @@ -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}!`); +}); \ No newline at end of file diff --git a/backend/src/lib/Auth.ts b/backend/src/lib/Auth.ts new file mode 100644 index 0000000..79f337d --- /dev/null +++ b/backend/src/lib/Auth.ts @@ -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 { + // 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("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("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("users").findOne({ id: session.userId }); + if (!user) { + return { state: false, message: "User Not Found" }; + } + + return { + state: true, + message: "Authenticated", + userId: session.userId, + user: user, + }; +} \ No newline at end of file diff --git a/backend/src/routes/account/api-keys.ts b/backend/src/routes/account/api-keys.ts new file mode 100644 index 0000000..17797b1 --- /dev/null +++ b/backend/src/routes/account/api-keys.ts @@ -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 }); + }) + ); + + diff --git a/backend/src/routes/account/delete.ts b/backend/src/routes/account/delete.ts new file mode 100644 index 0000000..9bd132a --- /dev/null +++ b/backend/src/routes/account/delete.ts @@ -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" }); + }) +); diff --git a/backend/src/routes/account/download.ts b/backend/src/routes/account/download.ts new file mode 100644 index 0000000..66f2d6b --- /dev/null +++ b/backend/src/routes/account/download.ts @@ -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("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)); + }) +); diff --git a/backend/src/routes/account/fetch.ts b/backend/src/routes/account/fetch.ts new file mode 100644 index 0000000..b096646 --- /dev/null +++ b/backend/src/routes/account/fetch.ts @@ -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 + }, + }); + }) +); diff --git a/backend/src/routes/account/login.ts b/backend/src/routes/account/login.ts new file mode 100644 index 0000000..4205252 --- /dev/null +++ b/backend/src/routes/account/login.ts @@ -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 }); + }) +); diff --git a/backend/src/routes/account/logout.ts b/backend/src/routes/account/logout.ts new file mode 100644 index 0000000..f7cf617 --- /dev/null +++ b/backend/src/routes/account/logout.ts @@ -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" }); + }) +); diff --git a/backend/src/routes/account/manage.ts b/backend/src/routes/account/manage.ts new file mode 100644 index 0000000..c8b94f0 --- /dev/null +++ b/backend/src/routes/account/manage.ts @@ -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" }); + }) +); diff --git a/backend/src/routes/account/profile.ts b/backend/src/routes/account/profile.ts new file mode 100644 index 0000000..bbec28b --- /dev/null +++ b/backend/src/routes/account/profile.ts @@ -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" }); + }) +); diff --git a/backend/src/routes/account/register.ts b/backend/src/routes/account/register.ts new file mode 100644 index 0000000..1ea73a8 --- /dev/null +++ b/backend/src/routes/account/register.ts @@ -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 }); + }) +); diff --git a/backend/src/routes/account/subject-colors.ts b/backend/src/routes/account/subject-colors.ts new file mode 100644 index 0000000..29b7841 --- /dev/null +++ b/backend/src/routes/account/subject-colors.ts @@ -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" }); + }) +); diff --git a/backend/src/routes/exams/bulk-create.ts b/backend/src/routes/exams/bulk-create.ts new file mode 100644 index 0000000..1131b0f --- /dev/null +++ b/backend/src/routes/exams/bulk-create.ts @@ -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 + }); + }) +); diff --git a/backend/src/routes/exams/create.ts b/backend/src/routes/exams/create.ts new file mode 100644 index 0000000..2910bee --- /dev/null +++ b/backend/src/routes/exams/create.ts @@ -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 + }); + }) +); diff --git a/backend/src/routes/exams/delete.ts b/backend/src/routes/exams/delete.ts new file mode 100644 index 0000000..69890d1 --- /dev/null +++ b/backend/src/routes/exams/delete.ts @@ -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" + }); + }) +); diff --git a/backend/src/routes/exams/get.ts b/backend/src/routes/exams/get.ts new file mode 100644 index 0000000..9f67a33 --- /dev/null +++ b/backend/src/routes/exams/get.ts @@ -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 }); + }) +); diff --git a/backend/src/routes/exams/list.ts b/backend/src/routes/exams/list.ts new file mode 100644 index 0000000..c632aed --- /dev/null +++ b/backend/src/routes/exams/list.ts @@ -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 + }); + }) +); diff --git a/backend/src/routes/exams/update.ts b/backend/src/routes/exams/update.ts new file mode 100644 index 0000000..8250270 --- /dev/null +++ b/backend/src/routes/exams/update.ts @@ -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 }); + }) +); diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..9a71758 --- /dev/null +++ b/backend/src/types.ts @@ -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; +} + +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 }; \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..95d888f --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "node16", + "strict": true, + "moduleResolution": "node16", + "lib": ["ES2022"], + "pretty": true, + "alwaysStrict": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "declaration": false, + "esModuleInterop": true, + "target": "ES2022", + "outDir": "dist", + "sourceMap": true, + "removeComments": true, + "allowUnusedLabels": false, + "allowUnreachableCode": false, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"] +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5578285 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + application: + image: node:24-slim + working_dir: /app + volumes: + - ./:/app + ports: + - "$PORT:8080" + env_file: + - .env + command: sh -c "npm i -g pnpm && cd frontend && pnpm install && pnpm run build && cd ../backend && pnpm install && pnpm run start" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:$PORT/health"] + interval: 30s + timeout: 10s + retries: 3 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3f5d862 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Exams — Manage your schedule + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..84f2800 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,33 @@ +{ + "name": "exam-manager-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.22.0", + "react-toastify": "^10.0.4", + "axios": "^1.6.7", + "lucide-react": "^0.323.0", + "date-fns": "^3.3.1", + "clsx": "^2.1.0", + "tailwind-merge": "^2.2.1" + }, + "devDependencies": { + "@types/node": "^20.11.16", + "@types/react": "^18.2.55", + "@types/react-dom": "^18.2.19", + "@vitejs/plugin-react": "^4.2.1", + "autoprefixer": "^10.4.17", + "postcss": "^8.4.35", + "tailwindcss": "^3.4.1", + "typescript": "^5.3.3", + "vite": "^5.1.0" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..5eec88d --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..697f94f --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,90 @@ +import { useState, useEffect } from 'react'; +import { Routes, Route, useNavigate, Navigate } from 'react-router-dom'; +import axios from 'axios'; +import { ToastContainer, toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import Login from './pages/Login'; +import Home from './pages/Home'; +import Navbar from './components/Navbar'; +import { User, ApiResponse } from './types'; +import { Loader2 } from 'lucide-react'; +import AccountPage from './pages/AccountPage'; + +function App() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + const checkAuth = async () => { + try { + const res = await axios.get>('/api/account/fetch'); + if (res.data.success) { + setUser(res.data.user); + } + } catch (err) { + setUser(null); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + checkAuth(); + }, []); + + const handleLogin = (user: User) => { + setUser(user); + navigate('/'); + }; + + const handleLogout = async () => { + try { + await axios.post('/api/account/logout'); + toast.success('Logged out successfully'); + } catch (err) { + console.error('Logout failed', err); + toast.error('Logout failed'); + } + setUser(null); + navigate('/login'); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {user && } +
+ + : } + /> + : } + /> + + ) : ( + + ) + } + /> + +
+ +
+ ); +} + +export default App; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx new file mode 100644 index 0000000..3b8066c --- /dev/null +++ b/frontend/src/components/Navbar.tsx @@ -0,0 +1,51 @@ +import { LogOut, GraduationCap } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { User } from '../types'; + +interface NavbarProps { + onLogout: () => void; + user: User; +} + +function Navbar({ onLogout, user }: NavbarProps) { + const navigate = useNavigate(); + return ( + + ); +} + +export default Navbar; diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..289d7fc --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,44 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: dark; + color: #f5f5f7; + background-color: #000000; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + min-height: 100vh; + background-color: #000000; +} + +#root { + width: 100%; + min-height: 100vh; +} + +.glass { + background: rgba(28, 28, 30, 0.7); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +.apple-button { + @apply px-4 py-2 rounded-full font-medium transition-all duration-200 active:scale-95; +} + +.apple-card { + @apply bg-apple-card rounded-apple-lg border border-white/5 overflow-hidden; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..3058f0b --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' +import { BrowserRouter } from 'react-router-dom' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/modals/ExamModal.tsx b/frontend/src/modals/ExamModal.tsx new file mode 100644 index 0000000..86b1ce1 --- /dev/null +++ b/frontend/src/modals/ExamModal.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import { X } from 'lucide-react'; +import { Exam } from '../types'; + +interface ExamModalProps { + show: boolean; + onClose: () => void; + onSubmit: (e: React.FormEvent) => void; + formData: { + name: string; + subject: string; + date: string; + description: string; + result: string; + duration: string; + }; + setFormData: React.Dispatch>; + editingExam: Exam | null; +} + +const ExamModal: React.FC = ({ + show, + onClose, + onSubmit, + formData, + setFormData, + editingExam +}) => { + if (!show) return null; + return ( +
+
+
+

{editingExam ? 'Exam Details' : 'New Schedule'}

+ +
+
+
+ + setFormData({...formData, name: e.target.value})} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50" + placeholder="e.g. Final Math Exam" + required + /> +
+
+
+ + setFormData({...formData, subject: e.target.value})} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50" + placeholder="Mathematics" + required + /> +
+
+ + setFormData({...formData, date: e.target.value})} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white [color-scheme:dark]" + required + /> +
+
+
+
+ + setFormData({ ...formData, result: e.target.value })} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50" + placeholder="e.g. Pending, 95/100" + /> +
+
+ + setFormData({...formData, duration: e.target.value})} + className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 focus:outline-none focus:ring-2 focus:ring-apple-accent/50 transition-all text-white placeholder:text-apple-muted/50" + placeholder="60" + min="0" + required + /> +
+
+
+ +