From 77b66da2f40644a4a952c9b85b1e8c7d18d6c0eb Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Wed, 1 Jul 2026 10:30:34 +0200 Subject: [PATCH 1/2] Backend comments --- app/backend/src/lib/services/logger.ts | 61 ++++++++++ app/backend/src/lib/services/network/index.ts | 49 ++++++++ app/backend/src/lib/services/sse.ts | 32 ++++++ app/backend/src/lib/services/storage.ts | 106 +++++++++++++++++- app/backend/src/lib/utils/file.ts | 5 + app/backend/src/lib/utils/mutex.ts | 16 +++ app/backend/src/lib/utils/network.ts | 10 ++ app/backend/src/lib/utils/time.ts | 8 ++ .../src/routes/api/auth/password/index.ts | 5 + .../src/routes/api/auth/verify/index.ts | 6 + app/backend/src/routes/api/download/index.ts | 5 + app/backend/src/routes/api/hash/index.ts | 6 + app/backend/src/routes/api/heartbeat/index.ts | 5 + app/backend/src/routes/api/hide/index.ts | 5 + app/backend/src/routes/api/lock/index.ts | 5 + app/backend/src/routes/api/prepare/index.ts | 5 + app/backend/src/routes/api/register/index.ts | 11 ++ app/backend/src/routes/api/report/index.ts | 5 + app/backend/src/routes/api/resources/index.ts | 5 + app/backend/src/routes/api/sse/admin/index.ts | 6 + .../src/routes/api/sse/student/index.ts | 5 + app/backend/src/routes/api/status/index.ts | 6 + app/backend/src/routes/api/time/index.ts | 6 + .../src/routes/api/upload/config/index.ts | 5 + .../src/routes/api/upload/exam/index.ts | 5 + .../src/routes/api/upload/files/index.ts | 6 + .../src/routes/api/upload/resources/index.ts | 5 + 27 files changed, 391 insertions(+), 3 deletions(-) diff --git a/app/backend/src/lib/services/logger.ts b/app/backend/src/lib/services/logger.ts index c585d78..e0b3335 100644 --- a/app/backend/src/lib/services/logger.ts +++ b/app/backend/src/lib/services/logger.ts @@ -29,6 +29,9 @@ class Logger { private logs: LogRecord[] = []; private logsMutex = new Mutex(); + /** + * Reads LOG_PATH and LOG_LEVEL from environment variables. + */ constructor() { if ((!process.env.LOG_LEVEL || !process.env.LOG_PATH) && (process.env.LOG_LEVEL !== "default" && process.env.LOG_LEVEL !== "debug")) { throw new Error(".env is not configured correctly!!!"); @@ -39,10 +42,17 @@ class Logger { this.logFilePath = join(this.logPath, LATEST_LOG_NAME); } + /** + * Returns whether the logger has been initialized. + * @returns The initialization state. + */ public isInitialized(){ return this.initialized; } + /** + * Creates the log directory and file. If a previous log file exists, it is archived. + */ public async init() { if (this.initialized) throw Error("Logger already initiated!"); @@ -59,6 +69,10 @@ class Logger { fs.writeFile(this.logFilePath, ""); } + /** + * Archives the previous log file by renaming it with a timestamp derived from its first line. + * If the file is empty, it is removed instead. + */ private async renameLatest() { const fl = await firstline(this.logFilePath); @@ -72,10 +86,22 @@ class Logger { await fs.rename(this.logFilePath, join(this.logPath, newName)); } + /** + * Formats a Dayjs timestamp for use in log output lines. + * @param timestamp - The timestamp to format. + * @returns The formatted time string. + */ private getFormatedTime(timestamp: Dayjs) { return timestamp.format(`ddd DD-MM-YYYY HH:mm:ss${this.logLevel === "debug" ? ":SSS" : ""} +01:00 (CET)`); } + /** + * Constructs a structured LogRecord with a UUID, timestamp, type, and optional metadata. + * @param type - The severity/type of the log entry. + * @param message - The log message. + * @param [opts] - Optional issuer and action metadata. + * @returns The constructed log record. + */ private async buildRecord(type: LogType, message: string, opts?: LogRecordOpts): Promise { const timestamp = getTime(); const uuid = uuidv4(); @@ -90,6 +116,11 @@ class Logger { } } + /** + * Appends a log record to the file, keeps an in-memory buffer of the last 500 records, + * and broadcasts the record to admin SSE clients. + * @param record - The log record to persist and broadcast. + */ private async writeRecord(record: LogRecord) { const formatedTime = this.getFormatedTime(dayjs(record.timestamp)); const raw = `[${ record.type[0]?.toUpperCase() }] ${formatedTime} | ${record.message}`; @@ -108,29 +139,59 @@ class Logger { sseManager.broadcast([record], "log"); } + /** + * Internal dispatch: builds a record for the given message and type, then writes it. + * @param message - The log message. + * @param type - The log severity. + * @param [opts] - Optional metadata. + */ private async log(message: string, type: LogType, opts?: LogRecordOpts) { const record = await this.buildRecord(type, message, opts); await this.writeRecord(record); } + /** + * Logs an info-level message. + * @param message - The message to log. + * @param [opts] - Optional metadata. + */ public async info(message: string, opts?: LogRecordOpts) { this.log(message, "infos", opts); } + /** + * Logs a warning-level message. + * @param message - The message to log. + * @param [opts] - Optional metadata. + */ public async warn(message: string, opts?: LogRecordOpts) { this.log(message, "warnings", opts); } + /** + * Logs an error-level message. + * @param message - The message to log. + * @param [opts] - Optional metadata. + */ public async error(message: string, opts?: LogRecordOpts) { this.log(message, "errors", opts); } + /** + * Logs a debug-level message. Only writes when the logger is in debug mode. + * @param message - The message to log. + * @param [opts] - Optional metadata. + */ public async debug(message: string, opts?: LogRecordOpts) { if (this.logLevel !== "debug") return; this.log(message, "debug", opts); } + /** + * Returns the in-memory buffer of recent log records (up to 500). + * @returns The log records. + */ public getLogs(): LogRecord[] { return this.logs; } diff --git a/app/backend/src/lib/services/network/index.ts b/app/backend/src/lib/services/network/index.ts index 69ad600..cafa6a2 100644 --- a/app/backend/src/lib/services/network/index.ts +++ b/app/backend/src/lib/services/network/index.ts @@ -13,11 +13,20 @@ class Network { private studentUpdates: Map = new Map(); private heartbeats: Map = new Map(); + /** + * Creates the mutex and starts the periodic heartbeat-check loop. + */ constructor() { this.studentsMutex = new Mutex(); this.runner(); } + /** + * Creates and stores a new Student entry with default values. + * @param ip - The student's IP address. + * @param time - The timestamp of first connection. + * @returns The newly created student. + */ private createStudent(ip: string, time: number) { const newStudent: Student = { ip, name: "", connected: true, hasInternet: false, sent: false, finished: false, since: time, attempts: 0, hidden: false, latestVersion: { hash: "", path: "" } }; @@ -26,6 +35,10 @@ class Network { return newStudent } + /** + * Creates a student entry for the given IP only if one does not already exist. + * @param ip - The student's IP address. + */ private createStudentIfNotExists(ip: string) { if (this.students.has(ip)) return; @@ -34,12 +47,20 @@ class Network { this.createStudent(ip, now); } + /** + * Schedules the periodic heartbeat-check loop at CHECK_INTERVAL (2s). + */ private runner() { this.callback().finally(() => { setTimeout(this.runner.bind(this), CHECK_INTERVAL); }); } + /** + * Iterates all known IPs, checks heartbeat freshness, updates connection state, + * and broadcasts pending student updates via SSE. + * Connection-loss events are debounced up to 3 attempts before triggering. + */ private async callback() { const unlock = await this.studentsMutex.lock(); try { @@ -83,10 +104,19 @@ class Network { } } + /** + * Records a heartbeat timestamp for the given IP, used to determine connection state. + * @param ip - The student's IP address. + */ public async recordHeartbeat(ip: string) { this.heartbeats.set(ip, unixTime()); } + /** + * Queues a partial student update for batch broadcast and applies it to the in-memory student record. + * @param ip - The student's IP address. + * @param update - The partial update to apply. + */ private update(ip: string, update: StudentUpdate) { if (!this.studentUpdates.has(ip)) { this.studentUpdates.set(ip, update); @@ -98,6 +128,11 @@ class Network { this.students.set(ip, { ...this.students.get(ip)!, ...this.studentUpdates.get(ip) }); } + /** + * Returns the student for a given IP address, creating one if absent. + * @param ip - The student's IP address. + * @returns The student record. + */ public async getStudent(ip: string): Promise { const unlock = await this.studentsMutex.lock(); try { @@ -109,6 +144,11 @@ class Network { } } + /** + * Finds a student by their registered name. Logs an error if duplicates exist (returns the last match). + * @param name - The student's full name. + * @returns The matching student, or undefined. + */ public async getStudentByName(name: string) { const unlock = await this.studentsMutex.lock(); @@ -130,6 +170,10 @@ class Network { return undefined; } + /** + * Returns all tracked students. + * @returns Array of all students. + */ public async getStudents(): Promise { const unlock = await this.studentsMutex.lock(); @@ -140,6 +184,11 @@ class Network { return students; } + /** + * Atomically applies a partial update to a student. Creates the student first if it does not exist. + * @param ip - The student's IP address. + * @param update - The partial update to apply. + */ public async addUpdate(ip: string, update: StudentUpdate) { const unlock = await this.studentsMutex.lock(); try { diff --git a/app/backend/src/lib/services/sse.ts b/app/backend/src/lib/services/sse.ts index d73a312..ac247a1 100644 --- a/app/backend/src/lib/services/sse.ts +++ b/app/backend/src/lib/services/sse.ts @@ -23,6 +23,12 @@ class SSEManager { this.encoder = new TextEncoder(); } + /** + * Writes an SSE event frame to a client. Removes the client on write failure. + * @param client - The target SSE client. + * @param event - The event name. + * @param data - The data payload to send. + */ private safeWrite(client: Client, event: SSEEvent, data: any) { try { client.res.write(`event: ${event}\ndata: ${JSON.stringify({ message: data })}\n\n`); @@ -32,6 +38,14 @@ class SSEManager { } } + /** + * Registers a new SSE client and starts a heartbeat interval (10s) to keep the connection alive. + * On first connect, sends the appropriate initialisation data (log history for admins, + * connection signal for students). + * @param ip - The client IP address. + * @param res - The Express response object. + * @param admin - Whether the client is an admin. + */ public async addClient(ip: string, res: Response, admin: boolean) { const client: Client = { ip, res, admin }; @@ -65,6 +79,11 @@ class SSEManager { } } + /** + * Deregisters an SSE client, clears its heartbeat interval, and ends the response. + * @param ip - The client IP address. + * @param admin - Whether the client is an admin. + */ public removeClient(ip: string, admin: boolean) { const client = admin ? this.admins.get(ip) : this.students.get(ip); if (client) { @@ -81,6 +100,12 @@ class SSEManager { } } + /** + * Sends an event to all connected clients of a given type (admin or student). + * @param message - The data to send. + * @param event - The event name. + * @param [admin=true] - Broadcast to admin clients (true) or student clients (false). + */ public broadcast(message: object, event: SSEEvent, admin: boolean = true) { const clients = admin ? this.admins : this.students; for (const client of clients.values()) { @@ -88,6 +113,13 @@ class SSEManager { } } + /** + * Sends an event to a single client identified by IP address. + * @param ip - The client IP address. + * @param message - The data to send. + * @param event - The event name. + * @param admin - Whether the target is an admin client. + */ public send(ip: string, message: object, event: SSEEvent, admin: boolean) { const client = admin ? this.admins.get(ip) : this.students.get(ip); diff --git a/app/backend/src/lib/services/storage.ts b/app/backend/src/lib/services/storage.ts index 85461e2..bbc20df 100644 --- a/app/backend/src/lib/services/storage.ts +++ b/app/backend/src/lib/services/storage.ts @@ -35,6 +35,9 @@ class Storage { resources!: string[]; version!: string; + // Temp password shown on first run, cleared once admin sets a new one + newPassword: string|undefined; + get locked(): boolean { return appState.locked; } @@ -49,12 +52,14 @@ class Storage { appState.timeOffset = value; } - - newPassword: string|undefined; + // SHA-256 hash of the stored admin password + private password!: string; private sessions: Map = new Map(); - private password!: string; + /** + * Resolves file and location paths from environment variables, falling back to defaults. + */ constructor() { if (!process.env.UPLOAD_PATH || !process.env.EXAM_FILE_NAME || !process.env.PASSWORD_FILE || !process.env.ROOT_PATH) { throw new Error(".env is not configured correctly!!!"); @@ -69,6 +74,11 @@ class Storage { this.timeOffset = 0 } + /** + * Initializes storage: loads the admin password, exam config, and app version; + * creates required directories if they do not exist. + * @throws {Error} If storage has already been initialized. + */ public async init() { if (this.initialized) { throw new Error("Storage already initialized!"); @@ -118,11 +128,20 @@ class Storage { logger.debug("Initialized storage."); } + /** + * Computes a SHA-256 base64 digest of the input string. + * @param data - The string to hash. + * @returns The base64-encoded hash. + */ private createHash(data: string) { const hashObject = crypto.createHash("sha256"); return hashObject.update(data).digest("base64"); } + /** + * Generates a random password, writes its SHA-256 hash to the password file, + * and stores the plain-text version in `newPassword` for first-time display. + */ private async generatePassword() { this.newPassword = Buffer.from(crypto.randomBytes(20)).toString("base64").replace('=', ''); const hash = this.createHash(this.newPassword); @@ -132,6 +151,11 @@ class Storage { logger.info("New password generated."); } + /** + * Moves a multer-uploaded file to the target directory, sanitizing the filename. + * @param location - The destination directory. + * @param file - The uploaded file to persist. + */ private async write(location: string, file: Express.Multer.File) { const sanitizedFileName = path.basename(file.originalname); const fullLocation = path.join(location, sanitizedFileName); @@ -141,10 +165,19 @@ class Storage { logger.debug(`Created file "${fullLocation}".`); } + /** + * Resolves a path relative to the current working directory. + * @param p - The relative path. + * @returns The absolute path. + */ private local(p: string) { return path.join(process.cwd(), p); } + /** + * Removes all files inside the given directory. + * @param directory - The directory to clear. + */ private async deleteContent(directory: string) { const files = await fs.readdir(directory); @@ -153,6 +186,11 @@ class Storage { } } + /** + * Copies a file from source to destination, then removes the source file. + * @param source - The path to the source file. + * @param destination - The destination path. + */ public async moveFile(source: string, destination: string) { try { await fs.copyFile(source, destination); @@ -163,10 +201,22 @@ class Storage { } } + /** + * Compares a plaintext password against the stored SHA-256 hash. + * @param password - The plaintext password to check. + * @returns Whether the password is correct. + */ public verifyPassword(password: string) { return this.password === this.createHash(password); } + /** + * Validates an admin session by ID, IP address, and expiry time. + * Expired sessions are automatically removed. + * @param id - The session ID. + * @param ip - The client IP to match against the session. + * @returns Whether the session is valid. + */ public async verifySession(id: string, ip: string) { if (!this.sessions.has(id)) { return false; @@ -189,6 +239,11 @@ class Storage { return true; } + /** + * Calibrates the server clock offset using a client-provided timestamp, + * accounting for average network latency. + * @param timestamp - The ISO timestamp from the client. + */ public setOffset(timestamp: string) { const offset = dayjs(timestamp).diff(dayjs()) - AVERAGE_LATENCY; logger.debug(`Time offset set to ${offset}`); @@ -197,6 +252,11 @@ class Storage { this.timeOffset = offset; } + /** + * Creates a new admin session valid for 2 hours. + * @param ip - The IP address of the admin. + * @returns The newly created session ID. + */ public async createSession(ip: string) { const id = crypto.randomUUID(); @@ -211,12 +271,20 @@ class Storage { return id; } + /** + * Saves the uploaded exam HTML file to the configured exam location. + * @param file - The uploaded exam file. + */ public async writeExam(file: Express.Multer.File) { await this.moveFile(file.path, this.examLocation); logger.debug(`Exam created at "${this.examLocation}".`); } + /** + * Persists a YAML exam configuration to disk and updates the in-memory config. + * @param conf - The exam configuration object. + */ public async writeConfig(conf: Yamlconf) { const yamlConfString = yaml.dump(conf); @@ -227,6 +295,10 @@ class Storage { logger.info(`Exam config modified`); } + /** + * Replaces all exam resource files with a new set of uploaded files. + * @param files - The new resource files. + */ public async writeResources(files: Express.Multer.File[]) { await this.deleteContent("public/resources"); this.resources = []; @@ -239,6 +311,13 @@ class Storage { logger.info(`Exam resources modified`); } + /** + * Saves student submission files with automatic versioning and computes an MD5 digest + * of the uploaded content. The files are stored under a date- and name-based directory tree. + * @param ip - The student's IP address. + * @param files - The uploaded submission files. + * @returns The MD5 hash of the submission, or an empty string on failure. + */ public async writeStudentFiles(ip: string, files: Express.Multer.File[]) { const student = await network.getStudent(ip); const name = student.name; @@ -290,6 +369,12 @@ class Storage { return hash; } + /** + * Finalizes a student's submission by writing the MD5 hash file and renaming the + * version folder with a `_validated` suffix. + * @param ip - The student's IP address. + * @returns Whether the validation succeeded. + */ public async validateAndEnd(ip: string) { const student = await network.getStudent(ip); const name = student.name; @@ -307,6 +392,11 @@ class Storage { return true; } + /** + * Lists the contents of a directory relative to `rootLocation`, with path-traversal protection. + * @param [relativePath] - The directory path relative to root. + * @returns An array of directory items, or false on error or traversal attempt. + */ public async readDir(relativePath?: PathLike) { const currentPath = relativePath?.toString() || ""; const absolutePath = join(this.rootLocation, currentPath); @@ -340,6 +430,10 @@ class Storage { } } + /** + * Returns the total and used disk space (in bytes) on the root storage mount. + * @returns Disk usage statistics. + */ public async getDiskUsage() { const path = this.rootLocation !== '.' ? this.rootLocation : '/'; @@ -351,6 +445,12 @@ class Storage { return { total, used }; } + /** + * Creates a zip archive of the selected directory items in a temporary location. + * Each item is validated against path-traversal before being added. + * @param items - The files and directories to include. + * @returns The filename of the created zip archive. + */ public prepareDownload(items: DirItem[]): Promise { return new Promise((resolve, reject) => { const archive = archiver("zip", { diff --git a/app/backend/src/lib/utils/file.ts b/app/backend/src/lib/utils/file.ts index d3459e1..1b6c66f 100644 --- a/app/backend/src/lib/utils/file.ts +++ b/app/backend/src/lib/utils/file.ts @@ -1,5 +1,10 @@ +/** + * Formats a byte count into a human-readable string (KB, MB, or GB). + * @param n - The number of bytes. + * @returns The formatted size string. + */ export function formatSize(n: number ) { if (n < 10000) return `${(n / 1000).toFixed(2)} KB`; if (n < 10000000) return `${(n / 1000000).toFixed(2)} MB`; diff --git a/app/backend/src/lib/utils/mutex.ts b/app/backend/src/lib/utils/mutex.ts index 4eb16b2..aa22ff7 100644 --- a/app/backend/src/lib/utils/mutex.ts +++ b/app/backend/src/lib/utils/mutex.ts @@ -8,6 +8,10 @@ class Mutex { private locked: number = 0; private waitList: ((value: () => void) => void)[] = [] + /** + * Creates a mutex with an optional maximum number of concurrent locks. + * @param [options] - Configuration options. + */ constructor(options?: Options) { if (options) { if (options.maxLocks) { @@ -16,6 +20,10 @@ class Mutex { } } + /** + * Acquires a lock. If at capacity, the caller is queued until a lock is released. + * @returns A function that releases the lock when called. + */ public lock(): Promise<() => void> { return new Promise((resolve, reject) => { if (this.locked >= this.maxLocks) { @@ -41,6 +49,10 @@ class Mutex { } } + /** + * Returns a one-shot unlock guard. Calling it more than once is a no-op. + * @returns The unlock function. + */ public createUnlock(): () => void { let hasUnlocked = false; @@ -52,6 +64,10 @@ class Mutex { } } + /** + * Returns the current number of acquired locks. + * @returns The lock count. + */ public isLocked() { return this.locked; } diff --git a/app/backend/src/lib/utils/network.ts b/app/backend/src/lib/utils/network.ts index b854392..9e63c05 100644 --- a/app/backend/src/lib/utils/network.ts +++ b/app/backend/src/lib/utils/network.ts @@ -1,5 +1,11 @@ import { type Request } from "express"; +/** + * Extracts the real client IP address from request headers, respecting proxies + * via `x-forwarded-for` and `x-real-ip`. + * @param req - The Express request object. + * @returns The client IP string. + */ export function getIp(req: Request) { if (!req) return "unknown"; @@ -10,6 +16,10 @@ export function getIp(req: Request) { return ip.replace("::ffff:", "") } +/** + * Returns the server's public URL from the URL environment variable. + * @returns The server URL, or an empty string. + */ export async function getUrl(): Promise { return process.env.URL || '' } diff --git a/app/backend/src/lib/utils/time.ts b/app/backend/src/lib/utils/time.ts index 306e619..c543db7 100644 --- a/app/backend/src/lib/utils/time.ts +++ b/app/backend/src/lib/utils/time.ts @@ -1,10 +1,18 @@ import dayjs from "dayjs"; import { appState } from "../state"; +/** + * Returns the current time adjusted by the stored offset (milliseconds). + * @returns The adjusted Dayjs object. + */ export function getTime() { return dayjs().add(appState.timeOffset, "ms"); } +/** + * Returns the adjusted current time as a Unix timestamp (seconds since epoch). + * @returns The unix timestamp. + */ export function unixTime() { return (getTime()).unix(); } diff --git a/app/backend/src/routes/api/auth/password/index.ts b/app/backend/src/routes/api/auth/password/index.ts index 1548805..3f1c444 100644 --- a/app/backend/src/routes/api/auth/password/index.ts +++ b/app/backend/src/routes/api/auth/password/index.ts @@ -2,6 +2,11 @@ import storage from "@/lib/services/storage"; import { type Request, type Response } from "express"; +/** + * Clears the "new password" flag after the admin has acknowledged and set a new password. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function passwordPatchHandler(req: Request, res: Response) { storage.newPassword = undefined; diff --git a/app/backend/src/routes/api/auth/verify/index.ts b/app/backend/src/routes/api/auth/verify/index.ts index 1081529..1c5f368 100644 --- a/app/backend/src/routes/api/auth/verify/index.ts +++ b/app/backend/src/routes/api/auth/verify/index.ts @@ -4,6 +4,12 @@ import storage from "@/lib/services/storage"; import { getIp } from "@/lib/utils/network"; import { type Request, type Response } from "express"; +/** + * Authenticates an admin by password. On success, sets the clock offset from the client + * timestamp, creates a session cookie, and redirects to the originally requested URL. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function verifyPostHandler(req: Request, res: Response) { const ip = getIp(req); const password = req.body.password; diff --git a/app/backend/src/routes/api/download/index.ts b/app/backend/src/routes/api/download/index.ts index e79fa5a..1184547 100644 --- a/app/backend/src/routes/api/download/index.ts +++ b/app/backend/src/routes/api/download/index.ts @@ -5,6 +5,11 @@ import fs from "fs"; import { PassThrough } from "stream"; import logger from "@/lib/services/logger"; +/** + * Downloads a prepared zip archive from a temporary file and removes it after streaming. + * @param req - The Express request object (contains :id param). + * @param res - The Express response object. + */ export async function downloadGetHandler(req: Request, res: Response) { const { id } = req.params; const sanitizedId = path.basename(id?.toString() as string); diff --git a/app/backend/src/routes/api/hash/index.ts b/app/backend/src/routes/api/hash/index.ts index 318ff5a..487e412 100644 --- a/app/backend/src/routes/api/hash/index.ts +++ b/app/backend/src/routes/api/hash/index.ts @@ -6,6 +6,12 @@ import storage from "@/lib/services/storage"; import { getIp } from "@/lib/utils/network"; import { type Request, type Response } from "express"; +/** + * Validates that the submitted hash matches the student's latest uploaded version, finalizes + * the submission, and marks the student as finished. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function hashPostHandler(req: Request, res: Response) { const ip = getIp(req); diff --git a/app/backend/src/routes/api/heartbeat/index.ts b/app/backend/src/routes/api/heartbeat/index.ts index 8435c01..ed12686 100644 --- a/app/backend/src/routes/api/heartbeat/index.ts +++ b/app/backend/src/routes/api/heartbeat/index.ts @@ -3,6 +3,11 @@ import network from "@lib/services/network"; import { getIp } from "@lib/utils/network"; import { type Request, type Response } from "express"; +/** + * Records a heartbeat from a student, updating their last-seen timestamp. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function heartbeatPostHandler(req: Request, res: Response) { try { const ip = getIp(req); diff --git a/app/backend/src/routes/api/hide/index.ts b/app/backend/src/routes/api/hide/index.ts index 22dd987..98022cf 100644 --- a/app/backend/src/routes/api/hide/index.ts +++ b/app/backend/src/routes/api/hide/index.ts @@ -4,6 +4,11 @@ import logger from "@/lib/services/logger"; import type { Student } from "@/lib/types/student"; import { type Request, type Response } from "express"; +/** + * Toggles the "hidden" visibility status of a student, triggered by the admin. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function hidePostHandler(req: Request, res: Response) { const student: Student = req.body.student; const ip = student.ip; diff --git a/app/backend/src/routes/api/lock/index.ts b/app/backend/src/routes/api/lock/index.ts index 836fb67..636904f 100644 --- a/app/backend/src/routes/api/lock/index.ts +++ b/app/backend/src/routes/api/lock/index.ts @@ -3,6 +3,11 @@ import sseManager from "@/lib/services/sse"; import storage from "@/lib/services/storage"; import { type Request, type Response } from "express"; +/** + * Toggles the exam lock state and notifies all students via SSE. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function lockPostHandler(req: Request, res: Response) { storage.locked = !storage.locked; diff --git a/app/backend/src/routes/api/prepare/index.ts b/app/backend/src/routes/api/prepare/index.ts index 105e74f..f24c5a5 100644 --- a/app/backend/src/routes/api/prepare/index.ts +++ b/app/backend/src/routes/api/prepare/index.ts @@ -3,6 +3,11 @@ import storage from "@/lib/services/storage"; import type { DirItem } from "@/lib/types/dirItem"; import { type Request, type Response } from "express"; +/** + * Creates a zip archive of the selected directory items and returns the archive filename. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function preparePostHandler(req: Request, res: Response) { const items: DirItem[] = req.body.items; diff --git a/app/backend/src/routes/api/register/index.ts b/app/backend/src/routes/api/register/index.ts index 7a48f2f..394f95a 100644 --- a/app/backend/src/routes/api/register/index.ts +++ b/app/backend/src/routes/api/register/index.ts @@ -4,6 +4,11 @@ import network from "@/lib/services/network"; import { getIp } from "@/lib/utils/network"; import { type Request, type Response } from "express"; +/** + * Validates that the requesting student's name matches their registered IP. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function registerPostHandler(req: Request, res: Response) { const name = req.body.name; @@ -16,6 +21,12 @@ export async function registerPostHandler(req: Request, res: Response) { return res.status(200).json({ status: true }); } +/** + * Registers or confirms a student's name. Rejects with a conflict if another student already + * registered the same name. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function registerPatchHandler(req: Request, res: Response) { const { surname, name } = req.body; const fullname = surname + ' ' + name; diff --git a/app/backend/src/routes/api/report/index.ts b/app/backend/src/routes/api/report/index.ts index 39f48ca..635968a 100644 --- a/app/backend/src/routes/api/report/index.ts +++ b/app/backend/src/routes/api/report/index.ts @@ -3,6 +3,11 @@ import type { Request, Response } from "express"; import network from "@/lib/services/network"; import { getIp } from "@/lib/utils/network"; +/** + * Records a student's internet connectivity status report. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function reportPostHandler(req: Request, res: Response) { const ip = getIp(req); const hasInternet = req.body.hasInternet; diff --git a/app/backend/src/routes/api/resources/index.ts b/app/backend/src/routes/api/resources/index.ts index 288ea6f..53b3bf8 100644 --- a/app/backend/src/routes/api/resources/index.ts +++ b/app/backend/src/routes/api/resources/index.ts @@ -4,6 +4,11 @@ import { type Request, type Response } from 'express'; import mime from 'mime-types'; import logger from '@/lib/services/logger'; +/** + * Serves an exam resource file by name, with MIME-type detection and path-traversal protection. + * @param req - The Express request object (contains :file param). + * @param res - The Express response object. + */ export async function resourcesGetHandler(req: Request, res: Response) { const file = req.params.file as string; diff --git a/app/backend/src/routes/api/sse/admin/index.ts b/app/backend/src/routes/api/sse/admin/index.ts index c8cf293..761a972 100644 --- a/app/backend/src/routes/api/sse/admin/index.ts +++ b/app/backend/src/routes/api/sse/admin/index.ts @@ -3,6 +3,12 @@ import sseManager from "@services/sse"; import storage from "@services/storage"; import { type Request, type Response } from "express"; +/** + * Establishes a Server-Sent Events connection for an admin. Verifies the session cookie + * before streaming; sends an error event if unauthorized. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function sseAdminHandler(req: Request, res: Response) { const ip = getIp(req); const sessionId = req.cookies?.sid; diff --git a/app/backend/src/routes/api/sse/student/index.ts b/app/backend/src/routes/api/sse/student/index.ts index 1657e6f..9188b21 100644 --- a/app/backend/src/routes/api/sse/student/index.ts +++ b/app/backend/src/routes/api/sse/student/index.ts @@ -2,6 +2,11 @@ import { getIp } from "@/lib/utils/network"; import sseManager from "@services/sse"; import { type Request, type Response } from "express"; +/** + * Establishes a Server-Sent Events connection for a student. No authentication required. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function sseStudentHandler(req: Request, res: Response) { const ip = getIp(req); diff --git a/app/backend/src/routes/api/status/index.ts b/app/backend/src/routes/api/status/index.ts index 0479f68..6f19a08 100644 --- a/app/backend/src/routes/api/status/index.ts +++ b/app/backend/src/routes/api/status/index.ts @@ -6,6 +6,12 @@ import storage from "@/lib/services/storage"; import type { Student } from "@/lib/types/student"; import { type Request, type Response } from "express"; +/** + * Toggles the "finished" status of a student, triggered by the admin. Notifies the affected + * student via SSE. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function statusPostHandler(req: Request, res: Response) { const student: Student = req.body.student; const ip = student.ip; diff --git a/app/backend/src/routes/api/time/index.ts b/app/backend/src/routes/api/time/index.ts index ac7aff8..ec88002 100644 --- a/app/backend/src/routes/api/time/index.ts +++ b/app/backend/src/routes/api/time/index.ts @@ -3,6 +3,12 @@ import storage from "@/lib/services/storage"; import { unixTime } from "@/lib/utils/time"; import { type Request, type Response } from "express"; +/** + * Synchronizes the server clock offset using a timestamp provided by the client. + * Returns the current adjusted server time. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function timePostHandler(req: Request, res: Response) { const timestamp: string = req.body.timestamp; storage.setOffset(timestamp) diff --git a/app/backend/src/routes/api/upload/config/index.ts b/app/backend/src/routes/api/upload/config/index.ts index 8a52084..cf8a9fa 100644 --- a/app/backend/src/routes/api/upload/config/index.ts +++ b/app/backend/src/routes/api/upload/config/index.ts @@ -3,6 +3,11 @@ import storage from "@/lib/services/storage"; import type { Yamlconf } from "@/lib/types/yamlconf"; import { type Request, type Response } from "express"; +/** + * Updates the exam YAML configuration. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function configPostHandler(req: Request, res: Response) { const config: Yamlconf = req.body.config; diff --git a/app/backend/src/routes/api/upload/exam/index.ts b/app/backend/src/routes/api/upload/exam/index.ts index a712f1c..8c229ad 100644 --- a/app/backend/src/routes/api/upload/exam/index.ts +++ b/app/backend/src/routes/api/upload/exam/index.ts @@ -2,6 +2,11 @@ import storage from "@/lib/services/storage"; import { type Request, type Response } from "express"; +/** + * Uploads a new exam HTML file, replacing the previous one. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function examPostHandler(req: Request, res: Response) { await storage.writeExam(req.file!); diff --git a/app/backend/src/routes/api/upload/files/index.ts b/app/backend/src/routes/api/upload/files/index.ts index 24f27dc..e09cd58 100644 --- a/app/backend/src/routes/api/upload/files/index.ts +++ b/app/backend/src/routes/api/upload/files/index.ts @@ -6,6 +6,12 @@ import storage from "@/lib/services/storage"; import { getIp } from "@/lib/utils/network"; import { type Request, type Response } from "express"; +/** + * Handles student file submissions. Validates the exam is not locked, the student is not + * already finished, and checks for missing required files before storing. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function filesPostHandler(req: Request, res: Response) { const ip = getIp(req); const files = req.files as Express.Multer.File[]; diff --git a/app/backend/src/routes/api/upload/resources/index.ts b/app/backend/src/routes/api/upload/resources/index.ts index 2e55f65..690cdb9 100644 --- a/app/backend/src/routes/api/upload/resources/index.ts +++ b/app/backend/src/routes/api/upload/resources/index.ts @@ -2,6 +2,11 @@ import storage from "@/lib/services/storage"; import { type Request, type Response } from "express"; +/** + * Replaces all exam resource files with an uploaded set. + * @param req - The Express request object. + * @param res - The Express response object. + */ export async function resourcesPostHandler(req: Request, res: Response) { await storage.writeResources(req.files as Express.Multer.File[]); From a7de666d06ff3eb5a591e8276357f469418de6ce Mon Sep 17 00:00:00 2001 From: Leny Bressoud Date: Wed, 1 Jul 2026 10:38:09 +0200 Subject: [PATCH 2/2] Frontend comments --- app/pages/src/lib/utils/hooks/useRelativeTime.ts | 6 ++++++ app/pages/src/lib/utils/signals/configure.ts | 2 ++ app/pages/src/lib/utils/signals/internet.ts | 15 +++++++++++++++ .../src/lib/utils/signals/notificationsStore.ts | 6 ++++++ 4 files changed, 29 insertions(+) diff --git a/app/pages/src/lib/utils/hooks/useRelativeTime.ts b/app/pages/src/lib/utils/hooks/useRelativeTime.ts index ac01f01..59dfaa0 100644 --- a/app/pages/src/lib/utils/hooks/useRelativeTime.ts +++ b/app/pages/src/lib/utils/hooks/useRelativeTime.ts @@ -7,6 +7,12 @@ import { signal } from "@preact/signals"; dayjs.extend(relativeTime); +/** + * Creates a reactive signal that displays the relative time from the given + * timestamp, automatically updating every minute. + * @param timestamp The ISO 8601 timestamp string to compute relative time from. + * @returns A signal containing the relative time string (e.g. "5 minutes ago"). + */ export function useRelativeTime(timestamp: string) { const text = signal(dayjs(timestamp).fromNow()); diff --git a/app/pages/src/lib/utils/signals/configure.ts b/app/pages/src/lib/utils/signals/configure.ts index 281392b..c58d5b1 100644 --- a/app/pages/src/lib/utils/signals/configure.ts +++ b/app/pages/src/lib/utils/signals/configure.ts @@ -12,12 +12,14 @@ export const STEPS: StepPair[] = [ export const currentStep = signal(1); export const totalSteps = STEPS.length; +/** Advances to the next configuration step if not already on the last step. */ export const nextStep = () => { if (currentStep.value < totalSteps) { currentStep.value++; } } +/** Goes back to the previous configuration step if not already on the first step. */ export const prevStep = () => { if (currentStep.value > 1) { currentStep.value--; diff --git a/app/pages/src/lib/utils/signals/internet.ts b/app/pages/src/lib/utils/signals/internet.ts index 534c897..3c5c166 100644 --- a/app/pages/src/lib/utils/signals/internet.ts +++ b/app/pages/src/lib/utils/signals/internet.ts @@ -3,6 +3,11 @@ import { signal } from "@preact/signals"; export const hasInternet = signal(false); +/** + * Checks device connectivity by attempting to reach gstatic.com/generate_204. + * @param ms Timeout in milliseconds before aborting the request. + * @returns Whether the external endpoint was reachable. + */ async function hasExternalInternetAccess(ms: number = 3000) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ms); @@ -25,6 +30,10 @@ async function hasExternalInternetAccess(ms: number = 3000) { } } +/** + * Sends the current internet connectivity status to the server. + * @param connected Whether the device has internet access. + */ async function reportStudent(connected: boolean) { fetch("/api/report", { method: "POST", @@ -37,6 +46,12 @@ async function reportStudent(connected: boolean) { }) } +/** + * Periodically checks connectivity and updates the shared signal, reporting + * changes to the server. + * @param ms Interval in milliseconds between connectivity checks. + * @returns The interval timer that can be used to stop the watcher. + */ export function startExternalConnectivityWatcher(ms: number = 10000): NodeJS.Timeout { const check = async () => { const connected = await hasExternalInternetAccess(); diff --git a/app/pages/src/lib/utils/signals/notificationsStore.ts b/app/pages/src/lib/utils/signals/notificationsStore.ts index d2e3702..45dcfb6 100644 --- a/app/pages/src/lib/utils/signals/notificationsStore.ts +++ b/app/pages/src/lib/utils/signals/notificationsStore.ts @@ -8,10 +8,16 @@ const MAX_NOTIFICATIONS = 3; export const notifications = signal([]); +/** Removes a notification from the store by its identifier. */ export const removeNotification = (id: string) => { notifications.value = notifications.value.filter(n => n.id !== id); }; +/** + * Adds a notification to the store. Automatically removes it after a timeout + * unless marked as infinite. Oldest notifications are evicted when the maximum + * count is exceeded. + */ export const addNotification = (notification: Notification) => { const id = uuidv4(); const current = [...notifications.value];