commit 66cbd8d88a3d1a968f8fcd49ac900108341343bb Author: Krasimir Nedelchev <19822240+kaykayehnn@users.noreply.github.com> Date: Fri Jul 3 07:55:10 2026 +0300 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3272dd1 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +NODE_ENV=development +PORT=4000 +DATABASE_URL=file:../data/app.db +ADMIN_PASSWORD=change-admin-password +STUDENT_PASSWORD=change-student-password +SESSION_SECRET=change-this-long-random-secret +PUBLIC_URL=http://localhost:4000 +JUDGE_VIEWPORT_WIDTH=800 +JUDGE_VIEWPORT_HEIGHT=600 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a318d2c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules +dist +.env +data +coverage +playwright-report +test-results +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e944f9a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +FROM mcr.microsoft.com/playwright:v1.49.1-jammy AS build + +WORKDIR /app + +COPY package.json package-lock.json ./ +COPY backend/package.json backend/package.json +COPY frontend/package.json frontend/package.json +RUN npm ci + +COPY . . +RUN npm run build + +FROM mcr.microsoft.com/playwright:v1.49.1-jammy + +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=4000 +ENV DATABASE_URL=file:/app/backend/data/app.db + +COPY package.json package-lock.json ./ +COPY backend/package.json backend/package.json +COPY frontend/package.json frontend/package.json +COPY --from=build /app/node_modules node_modules +COPY --from=build /app/backend/node_modules backend/node_modules +COPY --from=build /app/frontend/node_modules frontend/node_modules +RUN npm prune --omit=dev + +COPY --from=build /app/backend/dist backend/dist +COPY --from=build /app/backend/prisma backend/prisma +COPY --from=build /app/frontend/dist frontend/dist + +RUN mkdir -p /app/backend/data +VOLUME ["/app/backend/data"] + +EXPOSE 4000 +CMD ["node", "backend/dist/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9374e47 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Frontend Judge + +A small classroom judge for 11th grade students practicing basic HTML, CSS, and JavaScript. + +## Features + +- Student shared-password access with browser-based HTML/CSS/JS editors. +- Live iframe preview while students type. +- Optional student name stored in the browser and attached to submissions. +- Student-visible submission history per task and entered name. +- Admin shared-password dashboard. +- Ordered day groups with one group per task. +- Markdown task descriptions. +- Uploaded images or downloaded image URLs inserted into task markdown. +- Reference HTML/CSS/JS that generates an expected screenshot. +- Rule-based judging with DOM, text, attribute, CSS, element-count, and visual-match checks. +- SQLite persistence and generated images stored in a Docker volume. + +## Local Development + +```bash +cp .env.example .env +npm install +npm run prisma:generate +npm run dev +``` + +The backend runs on [http://localhost:4000](http://localhost:4000). +The SQLite tables are created automatically when the backend starts. + +For frontend hot reload in a second terminal: + +```bash +npm run dev --workspace frontend +``` + +Then open [http://localhost:5173](http://localhost:5173). + +## Docker + +Create a production `.env` next to `docker-compose.yml`: + +```bash +ADMIN_PASSWORD=your-admin-password +STUDENT_PASSWORD=your-student-password +SESSION_SECRET=a-long-random-secret +PUBLIC_URL=https://your-domain.example +``` + +Start the app: + +```bash +docker compose up --build -d +``` + +If your server uses the legacy Compose binary, use `docker-compose up --build -d`. + +Open: + +- Student app: `http://localhost:4000` +- Admin app: `http://localhost:4000/admin` + +Use HTTPS on the public server, ideally through your reverse proxy. + +## Judging Notes + +Visual matching is intentionally tolerant. The browser renders both the reference solution and student submission with the same viewport, then compares screenshots. Use structure and CSS checks for hard requirements, and visual matching as a broader similarity signal. + +The backend renders student JavaScript only inside Playwright, with short timeouts and network requests blocked during judging. The live preview uses a sandboxed iframe. diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..5e8a426 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,39 @@ +{ + "name": "@frontend-judge/backend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "prisma generate && tsc", + "start": "node dist/server.js", + "test": "vitest run", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev --name init" + }, + "dependencies": { + "@prisma/client": "^6.1.0", + "cookie-parser": "^1.4.7", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "helmet": "^8.0.0", + "multer": "^2.2.0", + "pixelmatch": "^6.0.0", + "playwright": "^1.49.1", + "pngjs": "^7.0.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@types/cookie-parser": "^1.4.8", + "@types/express": "^5.0.0", + "@types/multer": "^1.4.12", + "@types/node": "^22.10.2", + "@types/pixelmatch": "^5.2.6", + "@types/pngjs": "^6.0.5", + "prisma": "^6.1.0", + "supertest": "^7.0.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vitest": "^2.1.8" + } +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma new file mode 100644 index 0000000..c7637ff --- /dev/null +++ b/backend/prisma/schema.prisma @@ -0,0 +1,57 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +model DayGroup { + id String @id @default(cuid()) + title String + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + tasks Task[] +} + +model Task { + id String @id @default(cuid()) + title String + descriptionMarkdown String + groupId String + order Int @default(0) + visible Boolean @default(true) + starterHtml String @default("") + starterCss String @default("") + starterJs String @default("") + referenceHtml String @default("") + referenceCss String @default("") + referenceJs String @default("") + expectedScreenshotPath String? + visualThreshold Float @default(0.85) + passingScore Float @default(0.8) + checksJson String @default("[]") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + group DayGroup @relation(fields: [groupId], references: [id], onDelete: Cascade) + submissions Submission[] +} + +model Submission { + id String @id @default(cuid()) + taskId String + studentName String + studentKey String + html String + css String + js String + passed Boolean + score Float + resultJson String + createdAt DateTime @default(now()) + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) + + @@index([taskId, studentKey]) +} diff --git a/backend/src/app.ts b/backend/src/app.ts new file mode 100644 index 0000000..dfc4dc3 --- /dev/null +++ b/backend/src/app.ts @@ -0,0 +1,675 @@ +import crypto from "node:crypto"; +import dns from "node:dns/promises"; +import fs from "node:fs/promises"; +import net from "node:net"; +import path from "node:path"; +import cookieParser from "cookie-parser"; +import express from "express"; +import helmet from "helmet"; +import multer from "multer"; +import { z } from "zod"; +import { + clearAuthCookies, + hasAdmin, + hasStudentAccess, + normalizeStudentName, + requireAdmin, + requireStudentAccess, + setAdminCookie, + setStudentCookie, + studentKey +} from "./auth.js"; +import { config } from "./config.js"; +import { ensureDatabaseSchema, prisma } from "./db.js"; +import { generateExpectedScreenshot, judgeSubmission } from "./judge.js"; +import type { CheckDefinition } from "./types.js"; + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { fileSize: 5 * 1024 * 1024 } +}); + +const checkSchema = z.object({ + type: z.enum( + [ + "selector-exists", + "text-contains", + "attribute-equals", + "css-property", + "element-count", + "visual-match" + ], + { errorMap: () => ({ message: "Choose a supported check type." }) } + ), + selector: z.string().optional(), + value: z.string().optional(), + attribute: z.string().optional(), + property: z.string().optional(), + count: z.coerce.number({ invalid_type_error: "Enter a valid element count." }).int().nonnegative().optional(), + threshold: z.coerce.number({ invalid_type_error: "Enter a valid visual threshold." }).min(0).max(1).optional(), + weight: z.coerce.number({ invalid_type_error: "Enter a valid check weight." }).positive("Check weight must be greater than 0.").default(1), + message: z.string().trim().min(1, "Add feedback text for this check.") +}); + +const taskSchema = z.object({ + title: z.string().trim().min(1, "Add a task title."), + descriptionMarkdown: z.string().default(""), + groupId: z.string().trim().min(1, "Choose a day group for this task."), + order: z.coerce.number({ invalid_type_error: "Enter a valid task order." }).int("Task order must be a whole number.").default(0), + visible: z.boolean().default(true), + starterHtml: z.string().default(""), + starterCss: z.string().default(""), + starterJs: z.string().default(""), + referenceHtml: z.string().default(""), + referenceCss: z.string().default(""), + referenceJs: z.string().default(""), + visualThreshold: z + .coerce.number({ invalid_type_error: "Enter a valid visual threshold." }) + .min(0, "Visual threshold must be between 0 and 1.") + .max(1, "Visual threshold must be between 0 and 1.") + .default(0.85), + passingScore: z + .coerce.number({ invalid_type_error: "Enter a valid passing score." }) + .min(0, "Passing score must be between 0 and 1.") + .max(1, "Passing score must be between 0 and 1.") + .default(0.8), + checks: z.array(checkSchema).default([]), + regenerateExpected: z.boolean().default(true) +}).superRefine((task, context) => { + task.checks.forEach((check, index) => { + const label = `Check ${index + 1}`; + const needsSelector = check.type !== "visual-match"; + const needsValue = ["text-contains", "attribute-equals", "css-property"].includes(check.type); + + if (needsSelector && !check.selector?.trim()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["checks", index, "selector"], + message: `${label}: enter a CSS selector.` + }); + } + + if (needsValue && !check.value?.trim()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["checks", index, "value"], + message: `${label}: enter the expected value.` + }); + } + + if (check.type === "attribute-equals" && !check.attribute?.trim()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["checks", index, "attribute"], + message: `${label}: enter the attribute name.` + }); + } + + if (check.type === "css-property" && !check.property?.trim()) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["checks", index, "property"], + message: `${label}: enter the CSS property name.` + }); + } + + if (check.type === "element-count" && check.count === undefined) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["checks", index, "count"], + message: `${label}: enter the expected number of elements.` + }); + } + + if (check.type === "visual-match" && check.threshold !== undefined && (check.threshold < 0 || check.threshold > 1)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ["checks", index, "threshold"], + message: `${label}: visual threshold must be between 0 and 1.` + }); + } + }); +}); + +const submissionSchema = z.object({ + studentName: z.string().min(1).max(120), + html: z.string().max(50000), + css: z.string().max(50000), + js: z.string().max(50000) +}); + +function publicTask(task: { + id: string; + title: string; + descriptionMarkdown: string; + groupId: string; + order: number; + starterHtml: string; + starterCss: string; + starterJs: string; + expectedScreenshotPath: string | null; + visualThreshold: number; + passingScore: number; +}) { + return task; +} + +function taskWithChecks(task: { checksJson: string }) { + return { + ...task, + checks: parseChecks(task.checksJson), + checksJson: undefined + }; +} + +function parseChecks(checksJson: string): CheckDefinition[] { + try { + const parsed = JSON.parse(checksJson); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +function routeParam(value: string | string[] | undefined) { + return Array.isArray(value) ? value[0] : value ?? ""; +} + +function validationError(error: z.ZodError, title = "Please fix the highlighted fields.") { + return { + error: title, + details: error.issues.map((issue) => issue.message) + }; +} + +function isPrivateIp(address: string) { + if (net.isIPv4(address)) { + const parts = address.split(".").map(Number); + return ( + parts[0] === 10 || + parts[0] === 127 || + (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) || + (parts[0] === 192 && parts[1] === 168) || + (parts[0] === 169 && parts[1] === 254) + ); + } + + if (net.isIPv6(address)) { + return address === "::1" || address.startsWith("fc") || address.startsWith("fd") || address.startsWith("fe80"); + } + + return true; +} + +async function assertSafeDownloadUrl(rawUrl: string) { + const url = new URL(rawUrl); + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("Only http and https image URLs are allowed."); + } + + const records = await dns.lookup(url.hostname, { all: true }); + if (records.some((record) => isPrivateIp(record.address))) { + throw new Error("Image URLs must not resolve to private or local addresses."); + } +} + +function imageExtension(contentType: string) { + if (contentType.includes("png")) return "png"; + if (contentType.includes("jpeg") || contentType.includes("jpg")) return "jpg"; + if (contentType.includes("webp")) return "webp"; + if (contentType.includes("gif")) return "gif"; + return null; +} + +async function saveAsset(buffer: Buffer, contentType: string) { + const extension = imageExtension(contentType); + if (!extension) { + throw new Error("Only png, jpg, webp, and gif images are supported."); + } + + const fileName = `${crypto.randomUUID()}.${extension}`; + const assetPath = path.join(config.generatedDir, "assets", fileName); + await fs.mkdir(path.dirname(assetPath), { recursive: true }); + await fs.writeFile(assetPath, buffer); + return `/generated/assets/${fileName}`; +} + +async function maybeGenerateExpected(task: { + id: string; + referenceHtml: string; + referenceCss: string; + referenceJs: string; +}) { + if (!task.referenceHtml.trim() && !task.referenceCss.trim() && !task.referenceJs.trim()) { + return null; + } + + return generateExpectedScreenshot(task.id, { + html: task.referenceHtml, + css: task.referenceCss, + js: task.referenceJs + }); +} + +async function seedSampleData() { + const count = await prisma.dayGroup.count(); + if (count > 0) { + return; + } + + const group = await prisma.dayGroup.create({ + data: { title: "Day 1 - HTML basics", order: 1 } + }); + const task = await prisma.task.create({ + data: { + title: "Create a welcome button", + descriptionMarkdown: + "Build a tiny page with a heading and a button. The button should say **Start** and use a green background.", + groupId: group.id, + order: 1, + starterHtml: "

Welcome

\n", + starterCss: "body {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n}\nbutton {\n padding: 12px 18px;\n}", + referenceHtml: "

Welcome

\n", + referenceCss: + "body { display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f7fafc; }\nbutton { padding: 12px 18px; background: rgb(22, 163, 74); color: white; border: 0; border-radius: 6px; }", + checksJson: JSON.stringify([ + { + type: "selector-exists", + selector: "button", + message: "Add a button element." + }, + { + type: "text-contains", + selector: "button", + value: "Start", + message: "The button should contain the text Start." + }, + { + type: "css-property", + selector: "button", + property: "background-color", + value: "rgb(22, 163, 74)", + message: "Make the button background green." + }, + { + type: "visual-match", + threshold: 0.75, + message: "The page should roughly match the reference screenshot." + } + ]) + } + }); + + try { + const expectedScreenshotPath = await maybeGenerateExpected(task); + if (expectedScreenshotPath) { + await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } }); + } + } catch (error) { + console.warn("Could not generate sample expected screenshot:", error); + } +} + +export async function createApp() { + await fs.mkdir(config.generatedDir, { recursive: true }); + await ensureDatabaseSchema(); + await seedSampleData(); + + const app = express(); + app.disable("x-powered-by"); + app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginEmbedderPolicy: false + }) + ); + app.use(express.json({ limit: "2mb" })); + app.use(cookieParser()); + app.use("/generated", express.static(config.generatedDir)); + + app.get("/api/health", (_req, res) => { + res.json({ ok: true }); + }); + + app.post("/api/auth/admin", (req, res) => { + if (req.body?.password !== config.adminPassword) { + res.status(401).json({ error: "Invalid admin password." }); + return; + } + + setAdminCookie(res); + res.json({ ok: true }); + }); + + app.post("/api/auth/student", (req, res) => { + if (req.body?.password !== config.studentPassword) { + res.status(401).json({ error: "Invalid student password." }); + return; + } + + setStudentCookie(res); + res.json({ ok: true }); + }); + + app.post("/api/auth/logout", (_req, res) => { + clearAuthCookies(res); + res.json({ ok: true }); + }); + + app.get("/api/auth/me", (req, res) => { + res.json({ + admin: hasAdmin(req), + studentAccess: hasStudentAccess(req) + }); + }); + + app.get("/api/groups", requireStudentAccess, async (_req, res) => { + const groups = await prisma.dayGroup.findMany({ + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + include: { + tasks: { + where: { visible: true }, + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + select: { + id: true, + title: true, + descriptionMarkdown: true, + groupId: true, + order: true, + starterHtml: true, + starterCss: true, + starterJs: true, + expectedScreenshotPath: true, + visualThreshold: true, + passingScore: true + } + } + } + }); + res.json(groups); + }); + + app.get("/api/tasks/:id", requireStudentAccess, async (req, res) => { + const taskId = routeParam(req.params.id); + const task = await prisma.task.findFirst({ + where: { id: taskId, visible: true }, + select: { + id: true, + title: true, + descriptionMarkdown: true, + groupId: true, + order: true, + starterHtml: true, + starterCss: true, + starterJs: true, + expectedScreenshotPath: true, + visualThreshold: true, + passingScore: true + } + }); + if (!task) { + res.status(404).json({ error: "Task not found." }); + return; + } + + res.json(publicTask(task)); + }); + + app.get("/api/tasks/:id/submissions", requireStudentAccess, async (req, res) => { + const taskId = routeParam(req.params.id); + const name = normalizeStudentName(String(req.query.studentName ?? "")); + if (!name) { + res.json([]); + return; + } + + const submissions = await prisma.submission.findMany({ + where: { taskId, studentKey: studentKey(name) }, + orderBy: { createdAt: "desc" }, + take: 20 + }); + res.json(submissions.map((submission) => ({ ...submission, result: JSON.parse(submission.resultJson) }))); + }); + + app.post("/api/tasks/:id/submissions", requireStudentAccess, async (req, res) => { + const taskId = routeParam(req.params.id); + const parsed = submissionSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json(validationError(parsed.error, "Please fix the submission before sending it.")); + return; + } + + const task = await prisma.task.findFirst({ where: { id: taskId, visible: true } }); + if (!task) { + res.status(404).json({ error: "Task not found." }); + return; + } + + const studentName = normalizeStudentName(parsed.data.studentName); + const result = await judgeSubmission({ + taskId: task.id, + checksJson: task.checksJson, + expectedScreenshotPath: task.expectedScreenshotPath, + visualThreshold: task.visualThreshold, + passingScore: task.passingScore, + bundle: { + html: parsed.data.html, + css: parsed.data.css, + js: parsed.data.js + } + }); + const submission = await prisma.submission.create({ + data: { + taskId: task.id, + studentName, + studentKey: studentKey(studentName), + html: parsed.data.html, + css: parsed.data.css, + js: parsed.data.js, + passed: result.passed, + score: result.score, + resultJson: JSON.stringify(result) + } + }); + res.json({ submission: { ...submission, result }, result }); + }); + + app.get("/api/admin/groups", requireAdmin, async (_req, res) => { + const groups = await prisma.dayGroup.findMany({ + orderBy: [{ order: "asc" }, { createdAt: "asc" }], + include: { tasks: { orderBy: [{ order: "asc" }, { createdAt: "asc" }] } } + }); + res.json(groups.map((group) => ({ ...group, tasks: group.tasks.map(taskWithChecks) }))); + }); + + app.post("/api/admin/groups", requireAdmin, async (req, res) => { + const parsed = z.object({ title: z.string().min(1), order: z.coerce.number().int().default(0) }).safeParse(req.body); + if (!parsed.success) { + res.status(400).json(validationError(parsed.error, "Please fix the day group before saving it.")); + return; + } + const group = await prisma.dayGroup.create({ data: parsed.data }); + res.json(group); + }); + + app.put("/api/admin/groups/:id", requireAdmin, async (req, res) => { + const groupId = routeParam(req.params.id); + const parsed = z.object({ title: z.string().min(1), order: z.coerce.number().int().default(0) }).safeParse(req.body); + if (!parsed.success) { + res.status(400).json(validationError(parsed.error, "Please fix the day group before saving it.")); + return; + } + const group = await prisma.dayGroup.update({ where: { id: groupId }, data: parsed.data }); + res.json(group); + }); + + app.delete("/api/admin/groups/:id", requireAdmin, async (req, res) => { + const groupId = routeParam(req.params.id); + await prisma.dayGroup.delete({ where: { id: groupId } }); + res.json({ ok: true }); + }); + + app.post("/api/admin/tasks", requireAdmin, async (req, res) => { + const parsed = taskSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json(validationError(parsed.error, "Please fix the task before saving it.")); + return; + } + + const { checks, regenerateExpected, ...data } = parsed.data; + const task = await prisma.task.create({ + data: { + ...data, + checksJson: JSON.stringify(checks) + } + }); + + let expectedScreenshotPath = task.expectedScreenshotPath; + if (regenerateExpected) { + expectedScreenshotPath = await maybeGenerateExpected(task); + if (expectedScreenshotPath) { + await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } }); + } + } + + const created = await prisma.task.findUniqueOrThrow({ where: { id: task.id } }); + res.json(taskWithChecks(created)); + }); + + app.put("/api/admin/tasks/:id", requireAdmin, async (req, res) => { + const taskId = routeParam(req.params.id); + const parsed = taskSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json(validationError(parsed.error, "Please fix the task before saving it.")); + return; + } + + const { checks, regenerateExpected, ...data } = parsed.data; + const task = await prisma.task.update({ + where: { id: taskId }, + data: { + ...data, + checksJson: JSON.stringify(checks) + } + }); + + if (regenerateExpected) { + const expectedScreenshotPath = await maybeGenerateExpected(task); + if (expectedScreenshotPath) { + await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } }); + } + } + + const updated = await prisma.task.findUniqueOrThrow({ where: { id: taskId } }); + res.json(taskWithChecks(updated)); + }); + + app.post("/api/admin/tasks/:id/generate-expected", requireAdmin, async (req, res) => { + const taskId = routeParam(req.params.id); + const task = await prisma.task.findUnique({ where: { id: taskId } }); + if (!task) { + res.status(404).json({ error: "Task not found." }); + return; + } + const expectedScreenshotPath = await maybeGenerateExpected(task); + if (!expectedScreenshotPath) { + res.status(400).json({ error: "Add reference HTML, CSS, or JS first." }); + return; + } + const updated = await prisma.task.update({ where: { id: task.id }, data: { expectedScreenshotPath } }); + res.json(taskWithChecks(updated)); + }); + + app.delete("/api/admin/tasks/:id", requireAdmin, async (req, res) => { + const taskId = routeParam(req.params.id); + await prisma.task.delete({ where: { id: taskId } }); + res.json({ ok: true }); + }); + + app.get("/api/admin/submissions", requireAdmin, async (_req, res) => { + const submissions = await prisma.submission.findMany({ + orderBy: { createdAt: "desc" }, + take: 200, + include: { task: { select: { title: true, group: { select: { title: true } } } } } + }); + res.json(submissions.map((submission) => ({ ...submission, result: JSON.parse(submission.resultJson) }))); + }); + + app.post("/api/admin/submissions/:id/reevaluate", requireAdmin, async (req, res) => { + const submissionId = routeParam(req.params.id); + const submission = await prisma.submission.findUnique({ + where: { id: submissionId }, + include: { task: { include: { group: { select: { title: true } } } } } + }); + + if (!submission) { + res.status(404).json({ error: "Submission not found." }); + return; + } + + const result = await judgeSubmission({ + taskId: submission.taskId, + checksJson: submission.task.checksJson, + expectedScreenshotPath: submission.task.expectedScreenshotPath, + visualThreshold: submission.task.visualThreshold, + passingScore: submission.task.passingScore, + bundle: { + html: submission.html, + css: submission.css, + js: submission.js + } + }); + + const updated = await prisma.submission.update({ + where: { id: submission.id }, + data: { + passed: result.passed, + score: result.score, + resultJson: JSON.stringify(result) + }, + include: { task: { select: { title: true, group: { select: { title: true } } } } } + }); + + res.json({ ...updated, result }); + }); + + app.post("/api/admin/assets/upload", requireAdmin, upload.single("image"), async (req, res) => { + if (!req.file) { + res.status(400).json({ error: "Image file is required." }); + return; + } + + const url = await saveAsset(req.file.buffer, req.file.mimetype); + res.json({ url, markdown: `![Uploaded image](${url})` }); + }); + + app.post("/api/admin/assets/download", requireAdmin, async (req, res) => { + const parsed = z.object({ url: z.string().url() }).safeParse(req.body); + if (!parsed.success) { + res.status(400).json(validationError(parsed.error, "Enter a valid image URL.")); + return; + } + + await assertSafeDownloadUrl(parsed.data.url); + const response = await fetch(parsed.data.url, { redirect: "error" }); + const contentType = response.headers.get("content-type") ?? ""; + const length = Number(response.headers.get("content-length") ?? 0); + if (!response.ok || length > 5 * 1024 * 1024) { + res.status(400).json({ error: "Could not download a valid image under 5 MB." }); + return; + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const url = await saveAsset(buffer, contentType); + res.json({ url, markdown: `![Downloaded image](${url})` }); + }); + + if (config.nodeEnv === "production") { + app.use(express.static(config.frontendDistDir)); + app.get("*", (_req, res) => { + res.sendFile(path.join(config.frontendDistDir, "index.html")); + }); + } + + return app; +} diff --git a/backend/src/auth.ts b/backend/src/auth.ts new file mode 100644 index 0000000..021d97d --- /dev/null +++ b/backend/src/auth.ts @@ -0,0 +1,90 @@ +import crypto from "node:crypto"; +import type { NextFunction, Request, Response } from "express"; +import { config } from "./config.js"; + +const COOKIE_OPTIONS = { + httpOnly: true, + sameSite: "lax" as const, + secure: config.nodeEnv === "production", + maxAge: 1000 * 60 * 60 * 12 +}; + +const ADMIN_COOKIE = "fj_admin"; +const STUDENT_COOKIE = "fj_student"; + +type Role = "admin" | "student"; + +function sign(value: string) { + return crypto.createHmac("sha256", config.sessionSecret).update(value).digest("base64url"); +} + +function makeToken(role: Role) { + const payload = `${role}.${Date.now()}`; + return `${payload}.${sign(payload)}`; +} + +function verifyToken(token: string | undefined, role: Role) { + if (!token) { + return false; + } + + const parts = token.split("."); + if (parts.length !== 3) { + return false; + } + + const payload = `${parts[0]}.${parts[1]}`; + const expected = sign(payload); + if (parts[0] !== role || parts[2].length !== expected.length) { + return false; + } + + return crypto.timingSafeEqual(Buffer.from(parts[2]), Buffer.from(expected)); +} + +export function setAdminCookie(res: Response) { + res.cookie(ADMIN_COOKIE, makeToken("admin"), COOKIE_OPTIONS); +} + +export function setStudentCookie(res: Response) { + res.cookie(STUDENT_COOKIE, makeToken("student"), COOKIE_OPTIONS); +} + +export function clearAuthCookies(res: Response) { + res.clearCookie(ADMIN_COOKIE); + res.clearCookie(STUDENT_COOKIE); +} + +export function hasAdmin(req: Request) { + return verifyToken(req.cookies?.[ADMIN_COOKIE], "admin"); +} + +export function hasStudentAccess(req: Request) { + return hasAdmin(req) || verifyToken(req.cookies?.[STUDENT_COOKIE], "student"); +} + +export function requireAdmin(req: Request, res: Response, next: NextFunction) { + if (!hasAdmin(req)) { + res.status(401).json({ error: "Admin access required." }); + return; + } + + next(); +} + +export function requireStudentAccess(req: Request, res: Response, next: NextFunction) { + if (!hasStudentAccess(req)) { + res.status(401).json({ error: "Student access required." }); + return; + } + + next(); +} + +export function normalizeStudentName(name: string) { + return name.trim().replace(/\s+/g, " "); +} + +export function studentKey(name: string) { + return normalizeStudentName(name).toLocaleLowerCase("en-US"); +} diff --git a/backend/src/codeDocument.ts b/backend/src/codeDocument.ts new file mode 100644 index 0000000..0e91734 --- /dev/null +++ b/backend/src/codeDocument.ts @@ -0,0 +1,29 @@ +export type CodeBundle = { + html: string; + css: string; + js: string; +}; + +export function buildHtmlDocument(bundle: CodeBundle) { + return ` + + + + + + + + ${bundle.html} + + + +`; +} diff --git a/backend/src/config.ts b/backend/src/config.ts new file mode 100644 index 0000000..d9b430b --- /dev/null +++ b/backend/src/config.ts @@ -0,0 +1,49 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import dotenv from "dotenv"; + +const __filename = fileURLToPath(import.meta.url); +export const backendDir = path.resolve(path.dirname(__filename), ".."); +export const projectDir = path.resolve(backendDir, ".."); + +dotenv.config({ path: path.resolve(projectDir, ".env") }); +dotenv.config({ path: path.resolve(backendDir, ".env") }); + +export const config = { + nodeEnv: process.env.NODE_ENV ?? "development", + port: Number(process.env.PORT ?? 4000), + adminPassword: process.env.ADMIN_PASSWORD ?? "change-admin-password", + studentPassword: process.env.STUDENT_PASSWORD ?? "change-student-password", + sessionSecret: process.env.SESSION_SECRET ?? "dev-session-secret-change-me", + publicUrl: process.env.PUBLIC_URL ?? "http://localhost:4000", + viewport: { + width: Number(process.env.JUDGE_VIEWPORT_WIDTH ?? 800), + height: Number(process.env.JUDGE_VIEWPORT_HEIGHT ?? 600) + }, + dataDir: path.resolve(backendDir, "data"), + generatedDir: path.resolve(backendDir, "data", "generated"), + frontendDistDir: path.resolve(projectDir, "frontend", "dist") +}; + +export function assertProductionConfig() { + if (config.nodeEnv !== "production") { + return; + } + + const weakValues = new Set([ + "change-admin-password", + "change-student-password", + "dev-session-secret-change-me", + "change-this-long-random-secret" + ]); + + for (const [name, value] of [ + ["ADMIN_PASSWORD", config.adminPassword], + ["STUDENT_PASSWORD", config.studentPassword], + ["SESSION_SECRET", config.sessionSecret] + ]) { + if (!value || weakValues.has(value)) { + throw new Error(`${name} must be set to a strong value in production.`); + } + } +} diff --git a/backend/src/db.ts b/backend/src/db.ts new file mode 100644 index 0000000..5ec094f --- /dev/null +++ b/backend/src/db.ts @@ -0,0 +1,65 @@ +import { PrismaClient } from "@prisma/client"; + +export const prisma = new PrismaClient(); + +export async function ensureDatabaseSchema() { + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "DayGroup" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + `); + + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "Task" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "descriptionMarkdown" TEXT NOT NULL, + "groupId" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "visible" BOOLEAN NOT NULL DEFAULT true, + "starterHtml" TEXT NOT NULL DEFAULT '', + "starterCss" TEXT NOT NULL DEFAULT '', + "starterJs" TEXT NOT NULL DEFAULT '', + "referenceHtml" TEXT NOT NULL DEFAULT '', + "referenceCss" TEXT NOT NULL DEFAULT '', + "referenceJs" TEXT NOT NULL DEFAULT '', + "expectedScreenshotPath" TEXT, + "visualThreshold" REAL NOT NULL DEFAULT 0.85, + "passingScore" REAL NOT NULL DEFAULT 0.8, + "checksJson" TEXT NOT NULL DEFAULT '[]', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Task_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "DayGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE + ); + `); + + const taskColumns = await prisma.$queryRawUnsafe>(`PRAGMA table_info("Task");`); + if (!taskColumns.some((column) => column.name === "passingScore")) { + await prisma.$executeRawUnsafe(`ALTER TABLE "Task" ADD COLUMN "passingScore" REAL NOT NULL DEFAULT 0.8;`); + } + + await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "Submission" ( + "id" TEXT NOT NULL PRIMARY KEY, + "taskId" TEXT NOT NULL, + "studentName" TEXT NOT NULL, + "studentKey" TEXT NOT NULL, + "html" TEXT NOT NULL, + "css" TEXT NOT NULL, + "js" TEXT NOT NULL, + "passed" BOOLEAN NOT NULL, + "score" REAL NOT NULL, + "resultJson" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Submission_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task" ("id") ON DELETE CASCADE ON UPDATE CASCADE + ); + `); + + await prisma.$executeRawUnsafe(` + CREATE INDEX IF NOT EXISTS "Submission_taskId_studentKey_idx" ON "Submission" ("taskId", "studentKey"); + `); +} diff --git a/backend/src/judge.test.ts b/backend/src/judge.test.ts new file mode 100644 index 0000000..e445739 --- /dev/null +++ b/backend/src/judge.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { buildHtmlDocument } from "./codeDocument.js"; +import { summarizeWeightedResults } from "./judge.js"; + +describe("buildHtmlDocument", () => { + it("combines html, css, and js into a browser document", () => { + const document = buildHtmlDocument({ + html: "", + css: "button { color: red; }", + js: "document.body.dataset.ready = 'true';" + }); + + expect(document).toContain(""); + expect(document).toContain("button { color: red; }"); + expect(document).toContain("document.body.dataset.ready"); + }); +}); + +describe("summarizeWeightedResults", () => { + it("passes by default when 80 percent of equal-weight checks pass", () => { + const summary = summarizeWeightedResults([ + { type: "selector-exists", message: "one", passed: true }, + { type: "selector-exists", message: "two", passed: true }, + { type: "selector-exists", message: "three", passed: true }, + { type: "selector-exists", message: "four", passed: true }, + { type: "selector-exists", message: "five", passed: false } + ]); + + expect(summary).toEqual({ score: 0.8, passed: true }); + }); + + it("uses explicit check weights when calculating the score", () => { + const summary = summarizeWeightedResults([ + { type: "selector-exists", message: "important", passed: false, weight: 3 }, + { type: "selector-exists", message: "small", passed: true, weight: 1 } + ]); + + expect(summary).toEqual({ score: 0.25, passed: false }); + }); +}); diff --git a/backend/src/judge.ts b/backend/src/judge.ts new file mode 100644 index 0000000..8b6334e --- /dev/null +++ b/backend/src/judge.ts @@ -0,0 +1,312 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { PNG } from "pngjs"; +import pixelmatch from "pixelmatch"; +import { chromium } from "playwright"; +import { config } from "./config.js"; +import { buildHtmlDocument, type CodeBundle } from "./codeDocument.js"; +import type { CheckDefinition, CheckResult, JudgeResult } from "./types.js"; + +async function renderScreenshot(bundle: CodeBundle, outputPath: string) { + await fs.mkdir(path.dirname(outputPath), { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: config.viewport, + javaScriptEnabled: true + }); + + await context.route("**/*", (route) => { + const url = route.request().url(); + if (url.startsWith("data:") || url.startsWith("about:")) { + route.continue(); + return; + } + + route.abort(); + }); + + const page = await context.newPage(); + page.setDefaultTimeout(1500); + page.on("dialog", (dialog) => dialog.dismiss().catch(() => undefined)); + await page.setContent(buildHtmlDocument(bundle), { + waitUntil: "domcontentloaded", + timeout: 2000 + }); + await page.waitForTimeout(150); + await page.screenshot({ path: outputPath, fullPage: false }); + return { browser, page }; +} + +function parseChecks(checksJson: string): CheckDefinition[] { + try { + const checks = JSON.parse(checksJson); + return Array.isArray(checks) ? checks : []; + } catch { + return []; + } +} + +function formatPercent(value: number) { + return `${(value * 100).toFixed(1)}%`; +} + +function checkWeight(check: CheckDefinition) { + return typeof check.weight === "number" && Number.isFinite(check.weight) && check.weight > 0 ? check.weight : 1; +} + +export function summarizeWeightedResults(results: CheckResult[], passingScore = 0.8) { + const totalWeight = results.reduce((sum, result) => sum + (result.weight ?? 1), 0); + const passedWeight = results.reduce((sum, result) => sum + (result.passed ? result.weight ?? 1 : 0), 0); + const score = totalWeight === 0 ? 1 : Number((passedWeight / totalWeight).toFixed(4)); + + return { + score, + passed: score >= passingScore + }; +} + +type Pixel = { + r: number; + g: number; + b: number; + a: number; +}; + +function pixelAt(image: PNG, index: number): Pixel { + const offset = index * 4; + return { + r: image.data[offset], + g: image.data[offset + 1], + b: image.data[offset + 2], + a: image.data[offset + 3] + }; +} + +function colorDistance(a: Pixel, b: Pixel) { + const dr = a.r - b.r; + const dg = a.g - b.g; + const db = a.b - b.b; + const da = (a.a - b.a) / 2; + return Math.sqrt(dr * dr + dg * dg + db * db + da * da); +} + +function cornerBackground(image: PNG) { + return pixelAt(image, 0); +} + +function contentComparison(actual: PNG, expected: PNG) { + const actualBackground = cornerBackground(actual); + const expectedBackground = cornerBackground(expected); + const contentTolerance = 18; + const mismatchTolerance = 30; + let contentPixels = 0; + let contentMismatches = 0; + + for (let index = 0; index < actual.width * actual.height; index += 1) { + const actualPixel = pixelAt(actual, index); + const expectedPixel = pixelAt(expected, index); + const isExpectedContent = colorDistance(expectedPixel, expectedBackground) > contentTolerance; + const isActualContent = colorDistance(actualPixel, actualBackground) > contentTolerance; + const isMismatch = colorDistance(actualPixel, expectedPixel) > mismatchTolerance; + + if (isExpectedContent || isActualContent || isMismatch) { + contentPixels += 1; + if (isMismatch) { + contentMismatches += 1; + } + } + } + + if (contentPixels === 0) { + return { + similarity: 1, + contentPixels, + contentMismatches + }; + } + + return { + similarity: Number((1 - contentMismatches / contentPixels).toFixed(4)), + contentPixels, + contentMismatches + }; +} + +async function compareScreenshots(actualPath: string, expectedPath: string, diffPath: string) { + const [actualBuffer, expectedBuffer] = await Promise.all([ + fs.readFile(actualPath), + fs.readFile(expectedPath) + ]); + const actual = PNG.sync.read(actualBuffer); + const expected = PNG.sync.read(expectedBuffer); + + if (actual.width !== expected.width || actual.height !== expected.height) { + return { + similarity: 0, + diffPath: undefined, + details: `Screenshot size ${actual.width}x${actual.height} did not match expected ${expected.width}x${expected.height}.` + }; + } + + const diff = new PNG({ width: actual.width, height: actual.height }); + const mismatchedPixels = pixelmatch(actual.data, expected.data, diff.data, actual.width, actual.height, { + threshold: 0.12 + }); + await fs.mkdir(path.dirname(diffPath), { recursive: true }); + await fs.writeFile(diffPath, PNG.sync.write(diff)); + + const totalPixels = actual.width * actual.height; + const content = contentComparison(actual, expected); + return { + similarity: content.similarity, + diffPath, + details: `${content.contentMismatches} of ${content.contentPixels} content pixels differed. Full screenshot: ${mismatchedPixels} of ${totalPixels} pixels differed.` + }; +} + +export async function generateExpectedScreenshot(taskId: string, bundle: CodeBundle) { + const outputPath = path.join(config.generatedDir, "expected", `${taskId}.png`); + const { browser } = await renderScreenshot(bundle, outputPath); + await browser.close(); + return path.relative(config.dataDir, outputPath).replaceAll(path.sep, "/"); +} + +export async function judgeSubmission(options: { + taskId: string; + checksJson: string; + expectedScreenshotPath?: string | null; + visualThreshold: number; + passingScore?: number; + bundle: CodeBundle; +}): Promise { + const submissionScreenshot = path.join( + config.generatedDir, + "submissions", + `${options.taskId}-${Date.now()}.png` + ); + const { browser, page } = await renderScreenshot(options.bundle, submissionScreenshot); + const checks = parseChecks(options.checksJson); + const results: CheckResult[] = []; + let diffScreenshotPath: string | undefined; + + try { + for (const check of checks) { + if (check.type === "visual-match") { + const threshold = check.threshold ?? options.visualThreshold; + if (!options.expectedScreenshotPath) { + results.push({ + type: check.type, + message: check.message, + passed: false, + weight: checkWeight(check), + details: "No expected screenshot has been generated for this task." + }); + continue; + } + + const expectedPath = path.join(config.dataDir, options.expectedScreenshotPath); + const diffPath = path.join(config.generatedDir, "diffs", `${options.taskId}-${Date.now()}.png`); + const comparison = await compareScreenshots(submissionScreenshot, expectedPath, diffPath); + if (comparison.diffPath) { + diffScreenshotPath = path.relative(config.dataDir, comparison.diffPath).replaceAll(path.sep, "/"); + } + results.push({ + type: check.type, + message: check.message, + passed: comparison.similarity >= threshold, + score: comparison.similarity, + weight: checkWeight(check), + details: `${formatPercent(comparison.similarity)} match required ${formatPercent(threshold)}. ${comparison.details}` + }); + continue; + } + + if (!check.selector) { + results.push({ + type: check.type, + message: check.message, + passed: false, + weight: checkWeight(check), + details: "This check is missing a CSS selector." + }); + continue; + } + + const locator = page.locator(check.selector); + const count = await locator.count(); + + if (check.type === "selector-exists") { + results.push({ + type: check.type, + message: check.message, + passed: count > 0, + weight: checkWeight(check), + details: count > 0 ? `Found ${count} matching element(s).` : "No matching element was found." + }); + } + + if (check.type === "element-count") { + const expectedCount = check.count ?? Number(check.value ?? 0); + results.push({ + type: check.type, + message: check.message, + passed: count === expectedCount, + weight: checkWeight(check), + details: `Found ${count}, expected ${expectedCount}.` + }); + } + + if (check.type === "text-contains") { + const text = count > 0 ? await locator.first().textContent() : ""; + const expected = check.value ?? ""; + results.push({ + type: check.type, + message: check.message, + passed: Boolean(text?.toLocaleLowerCase("en-US").includes(expected.toLocaleLowerCase("en-US"))), + weight: checkWeight(check), + details: `Expected text containing "${expected}".` + }); + } + + if (check.type === "attribute-equals") { + const actual = count > 0 ? await locator.first().getAttribute(check.attribute ?? "") : null; + const expected = check.value ?? ""; + results.push({ + type: check.type, + message: check.message, + passed: actual === expected, + weight: checkWeight(check), + details: `Expected ${check.attribute}="${expected}", found ${actual ?? "nothing"}.` + }); + } + + if (check.type === "css-property") { + const actual = count > 0 + ? await locator.first().evaluate((el, property) => getComputedStyle(el).getPropertyValue(property), check.property ?? "") + : ""; + const expected = check.value ?? ""; + results.push({ + type: check.type, + message: check.message, + passed: actual.trim() === expected.trim(), + weight: checkWeight(check), + details: `Expected ${check.property}: ${expected}, found ${actual || "nothing"}.` + }); + } + } + } finally { + await browser.close(); + } + + const passingScore = options.passingScore ?? 0.8; + const summary = summarizeWeightedResults(results, passingScore); + return { + passed: summary.passed, + score: summary.score, + passingScore, + results, + screenshotPath: path.relative(config.dataDir, submissionScreenshot).replaceAll(path.sep, "/"), + diffScreenshotPath + }; +} diff --git a/backend/src/server.ts b/backend/src/server.ts new file mode 100644 index 0000000..7faaf1f --- /dev/null +++ b/backend/src/server.ts @@ -0,0 +1,10 @@ +import { assertProductionConfig, config } from "./config.js"; +import { createApp } from "./app.js"; + +assertProductionConfig(); + +const app = await createApp(); + +app.listen(config.port, () => { + console.log(`Frontend Judge listening on http://localhost:${config.port}`); +}); diff --git a/backend/src/types.ts b/backend/src/types.ts new file mode 100644 index 0000000..226ec5d --- /dev/null +++ b/backend/src/types.ts @@ -0,0 +1,38 @@ +export type CheckType = + | "selector-exists" + | "text-contains" + | "attribute-equals" + | "css-property" + | "element-count" + | "visual-match"; + +export type CheckDefinition = { + id?: string; + type: CheckType; + selector?: string; + value?: string; + attribute?: string; + property?: string; + count?: number; + threshold?: number; + weight?: number; + message: string; +}; + +export type CheckResult = { + type: CheckType; + message: string; + passed: boolean; + details?: string; + score?: number; + weight?: number; +}; + +export type JudgeResult = { + passed: boolean; + score: number; + passingScore: number; + results: CheckResult[]; + screenshotPath?: string; + diffScreenshotPath?: string; +}; diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..38fcc0d --- /dev/null +++ b/backend/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src", + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..78b0777 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + frontend-judge: + build: . + ports: + - "4000:4000" + environment: + NODE_ENV: production + PORT: 4000 + DATABASE_URL: file:/app/backend/data/app.db + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-admin-password} + STUDENT_PASSWORD: ${STUDENT_PASSWORD:-change-student-password} + SESSION_SECRET: ${SESSION_SECRET:-change-this-long-random-secret} + PUBLIC_URL: ${PUBLIC_URL:-http://localhost:4000} + JUDGE_VIEWPORT_WIDTH: ${JUDGE_VIEWPORT_WIDTH:-800} + JUDGE_VIEWPORT_HEIGHT: ${JUDGE_VIEWPORT_HEIGHT:-600} + volumes: + - frontend_judge_data:/app/backend/data + restart: unless-stopped + +volumes: + frontend_judge_data: diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5aa6f54 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Frontend Judge + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bb567de --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,28 @@ +{ + "name": "@frontend-judge/frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "tsc && vite build", + "preview": "vite preview --host 0.0.0.0" + }, + "dependencies": { + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.9", + "@codemirror/lang-javascript": "^6.2.2", + "@uiw/react-codemirror": "^4.23.7", + "lucide-react": "^0.468.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.5" + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..1e771c3 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,1175 @@ +import React, { useEffect, useMemo, useState } from "react"; +import ReactDOM from "react-dom/client"; +import CodeMirror from "@uiw/react-codemirror"; +import { css } from "@codemirror/lang-css"; +import { html } from "@codemirror/lang-html"; +import { javascript } from "@codemirror/lang-javascript"; +import ReactMarkdown from "react-markdown"; +import { + CheckCircle2, + Eye, + FileText, + History, + Image, + KeyRound, + LayoutDashboard, + ListPlus, + LogOut, + Maximize2, + Pencil, + Play, + Plus, + RefreshCw, + Save, + Trash2, + Upload, + X +} from "lucide-react"; +import "./styles.css"; + +type CheckType = + | "selector-exists" + | "text-contains" + | "attribute-equals" + | "css-property" + | "element-count" + | "visual-match"; + +type CheckDefinition = { + type: CheckType; + selector?: string; + value?: string; + attribute?: string; + property?: string; + count?: number; + threshold?: number; + weight?: number; + message: string; +}; + +type DayGroup = { + id: string; + title: string; + order: number; + tasks: Task[]; +}; + +type Task = { + id: string; + title: string; + descriptionMarkdown: string; + groupId: string; + order: number; + visible: boolean; + starterHtml: string; + starterCss: string; + starterJs: string; + referenceHtml?: string; + referenceCss?: string; + referenceJs?: string; + expectedScreenshotPath?: string | null; + visualThreshold: number; + passingScore: number; + checks?: CheckDefinition[]; +}; + +type JudgeResult = { + passed: boolean; + score: number; + results: Array<{ + type: CheckType; + message: string; + passed: boolean; + details?: string; + score?: number; + weight?: number; + }>; + screenshotPath?: string; + diffScreenshotPath?: string; + passingScore?: number; +}; + +type Submission = { + id: string; + taskId: string; + studentName: string; + html: string; + css: string; + js: string; + passed: boolean; + score: number; + createdAt: string; + result: JudgeResult; + task?: { title: string; group: { title: string } }; +}; + +class ApiError extends Error { + details: string[]; + + constructor(message: string, details: string[] = []) { + super(message); + this.name = "ApiError"; + this.details = details; + } +} + +const emptyTask: Omit = { + title: "", + descriptionMarkdown: "", + groupId: "", + order: 0, + visible: true, + starterHtml: "", + starterCss: "", + starterJs: "", + referenceHtml: "", + referenceCss: "", + referenceJs: "", + visualThreshold: 0.85, + passingScore: 0.8, + checks: [] +}; + +const passedTasksStorageKey = "frontendJudgePassedTaskIds"; + +function loadPassedTaskIds() { + try { + const raw = localStorage.getItem(passedTasksStorageKey); + const ids = raw ? JSON.parse(raw) : []; + return new Set(Array.isArray(ids) ? ids.filter((id): id is string => typeof id === "string") : []); + } catch { + return new Set(); + } +} + +function savePassedTaskIds(ids: Set) { + localStorage.setItem(passedTasksStorageKey, JSON.stringify([...ids])); +} + +function collectErrorDetails(payload: unknown): string[] { + if (!payload || typeof payload !== "object") { + return []; + } + + const record = payload as Record; + if (Array.isArray(record.details)) { + return record.details.filter((detail): detail is string => typeof detail === "string"); + } + + const error = record.error; + if (error && typeof error === "object") { + const errorRecord = error as Record; + const details: string[] = []; + + if (Array.isArray(errorRecord.formErrors)) { + details.push(...errorRecord.formErrors.filter((detail): detail is string => typeof detail === "string")); + } + + if (errorRecord.fieldErrors && typeof errorRecord.fieldErrors === "object") { + Object.entries(errorRecord.fieldErrors as Record).forEach(([field, messages]) => { + if (Array.isArray(messages)) { + messages.forEach((message) => { + if (typeof message === "string") { + details.push(`${field}: ${message}`); + } + }); + } + }); + } + + return details; + } + + return []; +} + +function errorMessage(err: unknown, fallback: string) { + if (err instanceof ApiError) { + return [err.message, ...err.details].join("\n"); + } + + if (err instanceof Error) { + return err.message; + } + + return fallback; +} + +async function api(url: string, options: RequestInit = {}): Promise { + const response = await fetch(url, { + ...options, + headers: { + ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), + ...options.headers + } + }); + + if (!response.ok) { + const payload = await response.json().catch(() => ({ error: response.statusText })); + const details = collectErrorDetails(payload); + const message = typeof payload.error === "string" ? payload.error : response.statusText || "Request failed."; + throw new ApiError(message, details); + } + + return response.json(); +} + +function buildPreview(htmlCode: string, cssCode: string, jsCode: string) { + return ` + + + + + + + ${htmlCode} + + + +`; +} + +function useAuth() { + const [auth, setAuth] = useState({ admin: false, studentAccess: false, loading: true }); + const refresh = () => + api<{ admin: boolean; studentAccess: boolean }>("/api/auth/me") + .then((data) => setAuth({ ...data, loading: false })) + .catch(() => setAuth({ admin: false, studentAccess: false, loading: false })); + + useEffect(() => { + refresh(); + }, []); + + return { auth, refresh }; +} + +function PasswordPanel(props: { + title: string; + endpoint: string; + onSuccess: () => void; +}) { + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [busy, setBusy] = useState(false); + + async function submit(event: React.FormEvent) { + event.preventDefault(); + setBusy(true); + setError(""); + try { + await api(props.endpoint, { method: "POST", body: JSON.stringify({ password }) }); + props.onSuccess(); + } catch (err) { + setError(errorMessage(err, "Could not sign in.")); + } finally { + setBusy(false); + } + } + + return ( +
+
+ +

{props.title}

+ + {error &&

{error}

} + + +
+ ); +} + +function Editor(props: { + label: string; + language: "html" | "css" | "js"; + value: string; + onChange: (value: string) => void; + readOnly?: boolean; +}) { + const extensions = props.language === "html" ? [html()] : props.language === "css" ? [css()] : [javascript()]; + return ( +
+
{props.label}
+ +
+ ); +} + +function StudentApp({ refreshAuth }: { refreshAuth: () => void }) { + const [groups, setGroups] = useState([]); + const [selectedTask, setSelectedTask] = useState(null); + const [studentName, setStudentName] = useState(localStorage.getItem("frontendJudgeStudentName") ?? ""); + const [htmlCode, setHtmlCode] = useState(""); + const [cssCode, setCssCode] = useState(""); + const [jsCode, setJsCode] = useState(""); + const [submissions, setSubmissions] = useState([]); + const [result, setResult] = useState(null); + const [passedTaskIds, setPassedTaskIds] = useState>(loadPassedTaskIds); + const [maximizedExpectedImage, setMaximizedExpectedImage] = useState<{ src: string; title: string } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + api("/api/groups").then(setGroups).catch((err) => setError(errorMessage(err, "Could not load tasks."))); + }, []); + + function selectTask(task: Task) { + setSelectedTask(task); + setHtmlCode(task.starterHtml); + setCssCode(task.starterCss); + setJsCode(task.starterJs); + setResult(null); + setMaximizedExpectedImage(null); + loadSubmissions(task.id, studentName); + } + + async function loadSubmissions(taskId = selectedTask?.id, name = studentName) { + if (!taskId || !name.trim()) { + setSubmissions([]); + return; + } + const data = await api(`/api/tasks/${taskId}/submissions?studentName=${encodeURIComponent(name)}`); + setSubmissions(data); + } + + async function submit() { + if (!selectedTask || !studentName.trim()) { + setError("Enter your name before submitting."); + return; + } + localStorage.setItem("frontendJudgeStudentName", studentName.trim()); + setBusy(true); + setError(""); + try { + const data = await api<{ result: JudgeResult }>(`/api/tasks/${selectedTask.id}/submissions`, { + method: "POST", + body: JSON.stringify({ studentName, html: htmlCode, css: cssCode, js: jsCode }) + }); + setResult(data.result); + if (data.result.passed) { + setPassedTaskIds((current) => { + const next = new Set(current).add(selectedTask.id); + savePassedTaskIds(next); + return next; + }); + } + await loadSubmissions(selectedTask.id, studentName); + } catch (err) { + setError(errorMessage(err, "Submission failed.")); + } finally { + setBusy(false); + } + } + + const preview = useMemo(() => buildPreview(htmlCode, cssCode, jsCode), [htmlCode, cssCode, jsCode]); + + return ( +
+
+
+

Frontend Judge

+

Practice HTML, CSS, and JavaScript in the browser.

+
+ +
+ +
+ + + {selectedTask ? ( +
+
+
+

{selectedTask.title}

+ {selectedTask.descriptionMarkdown} +
+ {selectedTask.expectedScreenshotPath && ( + + setMaximizedExpectedImage({ + src: imageUrl(selectedTask.expectedScreenshotPath)!, + title: `${selectedTask.title} expected result` + }) + } + /> + )} +
+ +
+
+ + + +
+
+
+ Live preview +
+