From 108f3f68e7e6412f27d5b0c1950ed80b7e72d2c5 Mon Sep 17 00:00:00 2001 From: vyas senthilkumar Date: Sat, 12 Sep 2026 07:40:57 +0530 Subject: [PATCH] Align project structure and Supabase runtime Add telemetry upload routes, gaze estimation, templated reports, and model asset boundaries while aligning local and Render setup with the current Supabase configuration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 24 +- examguard-pro/package-lock.json | 413 ++++++++++++++++++- render.yaml | 19 +- scripts/setup-supabase.ps1 | 2 +- scripts/start-dev.ps1 | 6 +- scripts/start-worker.ps1 | 30 +- server/.env.example | 39 +- server/api/router_v2.py | 4 +- server/api/uploads.py | 132 ++++++ server/config.py | 11 +- server/migrations/supabase_schema.sql | 3 +- server/reports/templates/session_report.html | 31 ++ server/requirements.txt | 1 + server/services/__init__.py | 4 + server/services/face_detection.py | 13 +- server/services/gaze_tracking.py | 99 +++++ server/services/weights/README.md | 10 + server/setup_database.py | 2 +- server/supabase_client.py | 4 +- server/workers/report_worker.py | 45 +- start.sh | 1 + 21 files changed, 827 insertions(+), 66 deletions(-) create mode 100644 server/api/uploads.py create mode 100644 server/reports/templates/session_report.html create mode 100644 server/services/gaze_tracking.py create mode 100644 server/services/weights/README.md diff --git a/README.md b/README.md index 605ec84..90d2d4b 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,10 @@ ExamGuard Pro is an advanced, real-time exam monitoring and proctoring system th │ ├── core/ # Event bus (Redis), risk engine, rate limiting, lifespan │ ├── schemas/ # Pydantic request/response schemas │ ├── services/ # AI pipeline: face_detection, gaze_tracking, ocr, -│ │ └── agents/ # object_detection, page_classifier, transformer_analysis, -│ │ # and site-classification agents +│ │ ├── agents/ # object_detection, page_classifier, transformer_analysis, +│ │ │ # and site-classification agents +│ │ └── weights/ # Downloaded model artifacts (ignored by Git) +│ ├── reports/templates/ # Jinja2 report templates used by report workers │ ├── workers/ # Celery workers: face, object, gaze, ocr, nlp, report, cleanup │ ├── migrations/ # SQL schema (supabase_schema.sql, 0001_init.sql) │ ├── tests/ # Pytest suite (Supabase mocked) @@ -85,8 +87,9 @@ cd server python -m venv .venv source .venv/bin/activate # On Windows: .venv\Scripts\activate pip install -r requirements.txt -# Optional: configure .env (copy from .env.example) with SUPABASE_URL, -# SUPABASE_KEY (service_role), and PG_* pooler credentials. +# Configure server/.env (copy from server/.env.example) with the new +# SUPABASE_URL, SUPABASE_SECRET_KEY, SUPABASE_JWKS_URL, and PG_* pooler +# credentials. Keep SUPABASE_SECRET_KEY server-side only. python setup_database.py --seed-admin # or paste migrations/supabase_schema.sql in the Supabase SQL Editor python -m uvicorn main:app --host 127.0.0.1 --port 8000 --reload ``` @@ -104,6 +107,9 @@ docker run -p 6379:6379 redis:7-alpine # or .\scripts\start-redis.ps1 # Terminal 2 — worker (from server/ with venv active) celery -A workers.celery_app worker --loglevel=info -Q face,object,gaze,ocr,nlp,report,default -c 2 ``` +Model weights belong in `server/services/weights/` and are intentionally not +committed. The MediaPipe face landmarker bundle remains at +`server/services/face_landmarker.task` for compatibility. Or run the whole stack with one command: `docker compose up --build` (API + Redis + worker + beat). ### 4. Frontend Setup @@ -122,6 +128,13 @@ Other scripts: `npm run test` (Vitest), `npm run lint` (TypeScript check). 3. Click **Load unpacked** and select the `extension/` directory — no build step required. 4. The backend URL defaults to `http://127.0.0.1:8000` (see `BACKEND_URL` in `extension/background.js`). +The V2 API exposes authenticated telemetry upload routes at +`/api/v1/uploads/screenshot`, `/api/v1/uploads/webcam`, and +`/api/v1/uploads/latest/{session_id}?type=screenshot|webcam`. Uploaded frames +are stored under `server/uploads/` locally and broadcast over the session +WebSocket; production deployments should use object storage for durable +retention. + ## 🧪 Testing ```bash cd server && python -m pytest tests/ -v # backend (Supabase mocked, no services needed) @@ -137,7 +150,8 @@ The repo ships a Render blueprint (`render.yaml`) — a single Python web servic 2. Build command: `bash build.sh` — installs Python deps, builds the dashboard, pre-downloads YOLO weights. 3. Start command: `bash start.sh` — runs `uvicorn main:app` from the repo root on `$PORT`. 4. Configure environment variables (secrets should be set in the Render dashboard, not committed): - - `SUPABASE_URL`, `SUPABASE_KEY` (service role recommended) + - `SUPABASE_URL`, `SUPABASE_SECRET_KEY`, `SUPABASE_JWKS_URL` + - `SUPABASE_PUBLISHABLE_KEY` only where a browser-side Supabase client is used - `PG_HOST`, `PG_PORT`, `PG_USER`, `PG_DB`, `PG_PASSWORD` (Supabase pooler, for direct DB access) - `SECRET_KEY` (auto-generated by the blueprint) - `CORS_ORIGINS` (comma-separated allowed origins) diff --git a/examguard-pro/package-lock.json b/examguard-pro/package-lock.json index 44e4c46..6311687 100644 --- a/examguard-pro/package-lock.json +++ b/examguard-pro/package-lock.json @@ -8,7 +8,9 @@ "name": "react-example", "version": "0.0.0", "dependencies": { + "@google/genai": "^1.29.0", "@tailwindcss/vite": "^4.1.14", + "@tanstack/react-query": "^5.101.2", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.0.4", @@ -29,7 +31,6 @@ "zustand": "^5.0.14" }, "devDependencies": { - "@tanstack/react-query": "^5.101.2", "@types/express": "^4.17.21", "@types/node": "^22.14.0", "autoprefixer": "^10.4.21", @@ -942,6 +943,30 @@ } } }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -987,6 +1012,63 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -1627,7 +1709,6 @@ "version": "5.101.2", "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz", "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==", - "dev": true, "license": "MIT", "funding": { "type": "github", @@ -1638,7 +1719,6 @@ "version": "5.101.2", "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz", "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==", - "dev": true, "license": "MIT", "dependencies": { "@tanstack/query-core": "5.101.2" @@ -1844,7 +1924,6 @@ "version": "22.19.15", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", - "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -1882,6 +1961,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, "node_modules/@types/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", @@ -2069,6 +2154,15 @@ "node": ">= 0.6" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2165,6 +2259,26 @@ "node": ">= 6" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", @@ -2187,6 +2301,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -2259,6 +2382,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2558,6 +2687,15 @@ "node": ">=12" } }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2685,6 +2823,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2947,6 +3094,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2964,6 +3117,29 @@ } } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -3033,6 +3209,18 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -3115,6 +3303,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3174,6 +3390,32 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3264,6 +3506,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3395,6 +3650,15 @@ "node": ">=6" } }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3407,6 +3671,27 @@ "node": ">=6" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -3656,6 +3941,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -3840,6 +4131,44 @@ "node": ">= 0.6" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/node-releases": { "version": "2.0.36", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", @@ -3870,6 +4199,19 @@ "node": ">= 0.8" } }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3968,6 +4310,29 @@ "dev": true, "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -4231,6 +4596,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -4750,7 +5124,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -5481,6 +5854,15 @@ "node": ">=18" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -5533,6 +5915,27 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", diff --git a/render.yaml b/render.yaml index 10187d6..8ae861c 100644 --- a/render.yaml +++ b/render.yaml @@ -1,5 +1,4 @@ services: - # ========== Unified Backend + Frontend (Monolithic) ========== - type: web name: examguard-api runtime: python @@ -10,18 +9,22 @@ services: envVars: - key: MPLBACKEND value: "Agg" - # Supabase Configuration + # Supabase configuration. Set secret values in Render's dashboard. - key: SUPABASE_URL - value: "https://fpnopsvzvfqwvyqmhgei.supabase.co" - - key: SUPABASE_KEY - value: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZwbm9wc3Z6dmZxd3Z5cW1oZ2VpIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzI1NDc4ODUsImV4cCI6MjA4ODEyMzg4NX0.OxJ9mXUJndY3QCjG_nYN5tLdEnyigJ265IrpxQB0dsU" - # PostgreSQL Configuration (Supabase Pooler) + value: "https://uymouuusicnnzhzdwznt.supabase.co" + - key: SUPABASE_PUBLISHABLE_KEY + sync: false + - key: SUPABASE_SECRET_KEY + sync: false + - key: SUPABASE_JWKS_URL + value: "https://uymouuusicnnzhzdwznt.supabase.co/auth/v1/.well-known/jwks.json" + # PostgreSQL configuration (Supabase pooler) - key: PG_HOST - value: "aws-1-ap-northeast-1.pooler.supabase.com" + sync: false - key: PG_PORT value: "6543" - key: PG_USER - value: "postgres.fpnopsvzvfqwvyqmhgei" + sync: false - key: PG_DB value: "postgres" - key: PG_PASSWORD diff --git a/scripts/setup-supabase.ps1 b/scripts/setup-supabase.ps1 index f61b4fb..323c513 100644 --- a/scripts/setup-supabase.ps1 +++ b/scripts/setup-supabase.ps1 @@ -34,4 +34,4 @@ try { } Write-Host "" -Write-Host "Done. Set SUPABASE_KEY to service_role key in server\.env for full API access." -ForegroundColor Green +Write-Host "Done. Set SUPABASE_SECRET_KEY to the server-only Supabase secret key in server\.env." -ForegroundColor Green diff --git a/scripts/start-dev.ps1 b/scripts/start-dev.ps1 index b0c0620..e8be00b 100644 --- a/scripts/start-dev.ps1 +++ b/scripts/start-dev.ps1 @@ -10,9 +10,9 @@ $VenvPython = Join-Path $VenvDir "Scripts\python.exe" Write-Host "=== ExamGuard Pro V2 — Dev Startup ===" -ForegroundColor Cyan -if (-not (Test-Path (Join-Path $Root ".env"))) { - Copy-Item (Join-Path $Root "server\.env.example") (Join-Path $Root ".env") - Write-Host "Created .env from server/.env.example" -ForegroundColor Yellow +if (-not (Test-Path (Join-Path $ServerDir ".env"))) { + Copy-Item (Join-Path $ServerDir ".env.example") (Join-Path $ServerDir ".env") + Write-Host "Created server\.env from server\.env.example" -ForegroundColor Yellow } if (-not (Test-Path $VenvPython)) { diff --git a/scripts/start-worker.ps1 b/scripts/start-worker.ps1 index bddd836..db4ef32 100644 --- a/scripts/start-worker.ps1 +++ b/scripts/start-worker.ps1 @@ -11,9 +11,33 @@ if (-not (Test-Path $VenvPython)) { exit 1 } -$env:REDIS_URL = "redis://localhost:6379/0" -$env:CELERY_BROKER_URL = "redis://localhost:6379/0" -$env:CELERY_RESULT_BACKEND = "redis://localhost:6379/0" +# Load simple KEY=VALUE entries from server/.env because Celery reads Redis +# settings directly from the process environment. +$EnvFile = Join-Path $ServerDir ".env" +if (Test-Path $EnvFile) { + Get-Content $EnvFile | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith("#") -and $line -match "^([^=]+)=(.*)$") { + $name = $matches[1].Trim() + $value = $matches[2].Trim().Trim('"') + if (-not (Get-Item "Env:$name" -ErrorAction SilentlyContinue)) { + Set-Item "Env:$name" $value + } + } + } +} + +# Do not overwrite managed Redis credentials from server/.env. These defaults +# are only for an unauthenticated local Redis container. +if (-not $env:REDIS_URL) { + $env:REDIS_URL = "redis://localhost:6379/0" +} +if (-not $env:CELERY_BROKER_URL) { + $env:CELERY_BROKER_URL = $env:REDIS_URL +} +if (-not $env:CELERY_RESULT_BACKEND) { + $env:CELERY_RESULT_BACKEND = $env:REDIS_URL +} Push-Location $ServerDir & $VenvPython -m celery -A workers.celery_app worker --loglevel=info -Q face,object,gaze,ocr,nlp,report,default -c 2 diff --git a/server/.env.example b/server/.env.example index b8bd42f..08addff 100644 --- a/server/.env.example +++ b/server/.env.example @@ -1,16 +1,33 @@ -# ExamGuard Pro - Server Environment (optional for local dev) -# Copy to server/.env and fill in values. The app runs without Supabase using in-memory fallbacks. +# ExamGuard Pro - Server Environment +# Copy to server/.env and fill in values. -# Supabase (optional — enables persistent storage) -# SUPABASE_URL=https://your-project.supabase.co -# SUPABASE_KEY=your-service-role-or-anon-key +# Supabase project and current key names +SUPABASE_URL=https://your-project-ref.supabase.co +SUPABASE_PUBLISHABLE_KEY=sb_publishable_your-public-key +SUPABASE_SECRET_KEY=sb_secret_your-server-only-key +SUPABASE_JWKS_URL=https://your-project-ref.supabase.co/auth/v1/.well-known/jwks.json -# JWT / auth -# SECRET_KEY=change-me-in-production +# Legacy alias supported for older deployments +# SUPABASE_KEY= -# Database (optional — defaults to SQLite at server/examguard.db) -# DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/examguard +# JWT / custom application auth +SECRET_KEY=replace-with-a-long-random-secret + +# Supabase PostgreSQL pooler +PG_HOST=your-pooler-host +PG_PORT=6543 +PG_USER=postgres.your-project-ref +PG_DB=postgres +PG_PASSWORD=your-database-password + +# Redis / Celery +REDIS_URL=redis://localhost:6379/0 +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/0 + +# Storage buckets +REPORTS_BUCKET=reports +SCREENSHOTS_BUCKET=screenshots # API -# API_HOST=0.0.0.0 -# API_PORT=8000 +CORS_ORIGINS=http://localhost:3000 diff --git a/server/api/router_v2.py b/server/api/router_v2.py index 3a2e84b..0af9f77 100644 --- a/server/api/router_v2.py +++ b/server/api/router_v2.py @@ -1,7 +1,7 @@ """ExamGuard Pro V2 — API router assembly.""" from fastapi import APIRouter -from api import auth, exams, sessions, events, reports, ws +from api import auth, exams, sessions, events, reports, uploads, ws api_router = APIRouter() @@ -10,6 +10,8 @@ api_router.include_router(sessions.router, prefix="/sessions", tags=["sessions"]) api_router.include_router(events.router, prefix="/events", tags=["events"]) api_router.include_router(reports.router, prefix="/reports", tags=["reports"]) +api_router.include_router(uploads.router, prefix="/uploads", tags=["uploads"]) +api_router.include_router(uploads.analysis_router, prefix="/analysis", tags=["analysis"]) api_router.include_router(ws.router, tags=["websocket"]) __all__ = ["api_router"] diff --git a/server/api/uploads.py b/server/api/uploads.py new file mode 100644 index 0000000..c106483 --- /dev/null +++ b/server/api/uploads.py @@ -0,0 +1,132 @@ +"""Telemetry image upload and latest-frame endpoints.""" +from __future__ import annotations + +import base64 +import binascii +import re +import uuid +from pathlib import Path + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +from config import settings +from deps import get_current_user, get_db +from workers.utils import publish_session_message + +router = APIRouter() +analysis_router = APIRouter() +_DATA_URL_RE = re.compile(r"^data:image/(?P[a-zA-Z0-9.+-]+);base64,(?P.+)$") +_EXTENSIONS = {"jpeg": ".jpg", "jpg": ".jpg", "png": ".png", "webp": ".webp"} + + +class ImageUpload(BaseModel): + session_id: str + timestamp: int | None = None + image_data: str = Field(min_length=16) + + +class AnalysisUpload(BaseModel): + session_id: str + timestamp: int | None = None + screen_image: str | None = None + webcam_image: str | None = None + is_dom_capture: bool = False + source_url: str | None = None + + +def _save_image(upload: ImageUpload, kind: str) -> Path: + match = _DATA_URL_RE.match(upload.image_data) + if not match: + raise HTTPException(status_code=415, detail="image_data must be a base64 data URL") + try: + content = base64.b64decode(match.group("data"), validate=True) + except (binascii.Error, ValueError) as exc: + raise HTTPException(status_code=422, detail="Invalid base64 image data") from exc + if not content: + raise HTTPException(status_code=422, detail="Image payload is empty") + + directory = settings.SCREENSHOTS_DIR if kind == "screenshot" else settings.WEBCAM_DIR + directory.mkdir(parents=True, exist_ok=True) + extension = _EXTENSIONS.get(match.group("format").lower(), ".bin") + path = directory / f"{upload.session_id}-{uuid.uuid4().hex}{extension}" + path.write_bytes(content) + return path + + +async def _store_and_publish(upload: ImageUpload, kind: str) -> dict: + path = _save_image(upload, kind) + publish_session_message(upload.session_id, { + "type": "telemetry_frame", + "session_id": upload.session_id, + "kind": kind, + "path": path.name, + }) + return {"status": "accepted", "session_id": upload.session_id, "filename": path.name} + + +@router.post("/screenshot") +async def upload_screenshot(upload: ImageUpload, user=Depends(get_current_user)): + return await _store_and_publish(upload, "screenshot") + + +@router.post("/webcam") +async def upload_webcam(upload: ImageUpload, user=Depends(get_current_user)): + return await _store_and_publish(upload, "webcam") + + +@analysis_router.post("/process") +async def process_analysis(upload: AnalysisUpload, user=Depends(get_current_user)): + """Accept the extension's legacy combined capture payload. + + Storage and inference are deliberately separate: the upload is acknowledged + immediately while the existing Celery event pipeline handles analysis. + """ + accepted: list[str] = [] + if upload.screen_image: + await _store_and_publish( + ImageUpload(session_id=upload.session_id, timestamp=upload.timestamp, image_data=upload.screen_image), + "screenshot", + ) + accepted.append("screenshot") + if upload.webcam_image: + await _store_and_publish( + ImageUpload(session_id=upload.session_id, timestamp=upload.timestamp, image_data=upload.webcam_image), + "webcam", + ) + accepted.append("webcam") + return { + "status": "accepted", + "session_id": upload.session_id, + "accepted": accepted, + "face_detected": None, + "phone_detected": None, + } + + +@router.get("/latest/{session_id}") +async def latest_frame( + session_id: str, + type: str = "screenshot", + user=Depends(get_current_user), + db=Depends(get_db), +): + if type not in {"screenshot", "webcam"}: + raise HTTPException(status_code=400, detail="type must be screenshot or webcam") + if db: + session = db.table("sessions").select("id, student_id").eq("id", session_id).execute() + if not session.data: + raise HTTPException(status_code=404, detail="Session not found") + if user.get("role") == "student" and session.data[0].get("student_id") != user["id"]: + raise HTTPException(status_code=403, detail="Access denied") + + directory = settings.SCREENSHOTS_DIR if type == "screenshot" else settings.WEBCAM_DIR + candidates = sorted( + (path for path in directory.glob(f"{session_id}-*") if path.is_file()), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + if not candidates: + raise HTTPException(status_code=404, detail="No telemetry frame available") + return FileResponse(candidates[0]) diff --git a/server/config.py b/server/config.py index fa2cb61..36017b8 100644 --- a/server/config.py +++ b/server/config.py @@ -19,6 +19,11 @@ class Settings(BaseSettings): REPORTS_DIR: Path = UPLOAD_DIR / "reports" SUPABASE_URL: str = Field(default="", env="SUPABASE_URL") + # Prefer Supabase's current key names. SUPABASE_KEY remains supported for + # older deployments and local environments. + SUPABASE_PUBLISHABLE_KEY: str = Field(default="", env="SUPABASE_PUBLISHABLE_KEY") + SUPABASE_SECRET_KEY: str = Field(default="", env="SUPABASE_SECRET_KEY") + SUPABASE_JWKS_URL: str = Field(default="", env="SUPABASE_JWKS_URL") SUPABASE_KEY: str = Field(default="", env="SUPABASE_KEY") SUPABASE_DB_PASSWORD: str = Field(default="", env="SUPABASE_DB_PASSWORD") @@ -104,7 +109,11 @@ def cors_origins_list(self) -> List[str]: # Legacy V1 service compatibility SUPABASE_URL = settings.SUPABASE_URL -SUPABASE_KEY = settings.SUPABASE_KEY +SUPABASE_PUBLISHABLE_KEY = settings.SUPABASE_PUBLISHABLE_KEY +SUPABASE_SECRET_KEY = settings.SUPABASE_SECRET_KEY +SUPABASE_JWKS_URL = settings.SUPABASE_JWKS_URL +# The server uses the secret key for database/storage administration. +SUPABASE_KEY = settings.SUPABASE_SECRET_KEY or settings.SUPABASE_KEY SUPABASE_DB_PASSWORD = settings.SUPABASE_DB_PASSWORD DATABASE_URL = settings.ASYNC_DATABASE_URL RISK_WEIGHTS = settings.RISK_WEIGHTS diff --git a/server/migrations/supabase_schema.sql b/server/migrations/supabase_schema.sql index 78602dc..1c8b2ab 100644 --- a/server/migrations/supabase_schema.sql +++ b/server/migrations/supabase_schema.sql @@ -4,7 +4,8 @@ -- Run once in: Supabase Dashboard → SQL Editor → New query → Run -- Or locally: python setup_database.py -- --- Backend tip: set SUPABASE_KEY to the **service_role** key (Settings → API) +-- Backend tip: set SUPABASE_SECRET_KEY to the server-only Supabase secret key +-- (Project Settings → API). -- so the FastAPI server can read/write all tables (custom JWT auth, not Supabase Auth). -- ============================================================================= diff --git a/server/reports/templates/session_report.html b/server/reports/templates/session_report.html new file mode 100644 index 0000000..8455c83 --- /dev/null +++ b/server/reports/templates/session_report.html @@ -0,0 +1,31 @@ + + + + + ExamGuard Pro - Session Report + + + +

ExamGuard Pro - Session Report

+

Session ID: {{ session.id }}

+

Risk Score: {{ session.risk_score|default(0) }} ({{ session.risk_level|default("safe") }})

+

Generated: {{ generated_at }}

+

Events

+ + + + {% for event in events %} + + {% else %} + + {% endfor %} + +
TimeTypeWeight
{{ event.created_at|default("") }}{{ event.type|default("") }}{{ event.weight|default(0) }}
No recorded events.
+ + diff --git a/server/requirements.txt b/server/requirements.txt index cfef8fb..dda1219 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -24,5 +24,6 @@ pytest pytest-asyncio fakeredis weasyprint +Jinja2 Pillow ruff diff --git a/server/services/__init__.py b/server/services/__init__.py index 38acfbe..5072731 100644 --- a/server/services/__init__.py +++ b/server/services/__init__.py @@ -15,6 +15,8 @@ "get_transformer_analyzer", "classify_page", "classify_for_tracker", + "estimate_head_pose", + "estimate_gaze", ] # Mapping of public name -> (submodule, attribute) @@ -26,6 +28,8 @@ "get_transformer_analyzer": ("transformer_analysis", "get_transformer_analyzer"), "classify_page": ("page_classifier", "classify_page"), "classify_for_tracker": ("page_classifier", "classify_for_tracker"), + "estimate_head_pose": ("gaze_tracking", "estimate_head_pose"), + "estimate_gaze": ("gaze_tracking", "estimate_gaze"), } diff --git a/server/services/face_detection.py b/server/services/face_detection.py index 7e995e3..96c52ae 100644 --- a/server/services/face_detection.py +++ b/server/services/face_detection.py @@ -110,7 +110,7 @@ def _analyze_tasks(self, frame, results): results['violations'].append('MULTIPLE_FACES_DETECTED') results['integrity_score_impact'] += 50 - for face_landmarks in detection_result.face_landmarks: + for face_index, face_landmarks in enumerate(detection_result.face_landmarks): # Get bounding box for detection x_coords = [lm.x for lm in face_landmarks] y_coords = [lm.y for lm in face_landmarks] @@ -121,6 +121,17 @@ def _analyze_tasks(self, frame, results): 'w': int((max(x_coords) - min(x_coords)) * w), 'h': int((max(y_coords) - min(y_coords)) * h) }) + if face_index == 0: + try: + from services.gaze_tracking import estimate_gaze, estimate_head_pose + + results["pose"] = estimate_head_pose(face_landmarks, w, h) + gaze = estimate_gaze(face_landmarks) + results["gaze_direction"] = gaze["direction"] + results["gaze_ratio"] = gaze["ratio"] + results["gaze_off_screen"] = gaze["off_screen"] + except (ValueError, cv2.error): + results["pose"] = None else: elapsed = time.time() - self._last_face_time if elapsed > self.FACE_ABSENT_THRESHOLD_SEC: diff --git a/server/services/gaze_tracking.py b/server/services/gaze_tracking.py new file mode 100644 index 0000000..ff300e4 --- /dev/null +++ b/server/services/gaze_tracking.py @@ -0,0 +1,99 @@ +"""Head-pose and coarse gaze estimation helpers. + +The helpers intentionally operate on MediaPipe's normalized face landmarks so +the worker can use them without coupling the API layer to a model runtime. +""" +from __future__ import annotations + +from math import atan2, degrees, hypot, asin +from typing import Any + +import cv2 +import numpy as np + + +POSE_LANDMARKS = (1, 199, 33, 263, 61, 291) +POSE_MODEL_POINTS = np.array( + [ + (0.0, 0.0, 0.0), + (0.0, -63.6, -12.5), + (-43.3, 32.7, -26.0), + (43.3, 32.7, -26.0), + (-28.9, -28.9, -24.1), + (28.9, -28.9, -24.1), + ], + dtype=np.float64, +) + + +def _point(landmark: Any, width: int, height: int) -> tuple[float, float]: + return float(landmark.x * width), float(landmark.y * height) + + +def estimate_head_pose( + landmarks: list[Any], width: int, height: int +) -> dict[str, float] | None: + """Estimate pitch, yaw, and roll in degrees from six face landmarks.""" + if len(landmarks) <= max(POSE_LANDMARKS): + return None + + image_points = np.array( + [_point(landmarks[index], width, height) for index in POSE_LANDMARKS], + dtype=np.float64, + ) + focal_length = float(width) + camera_matrix = np.array( + [ + [focal_length, 0, width / 2], + [0, focal_length, height / 2], + [0, 0, 1], + ], + dtype=np.float64, + ) + success, rotation_vector, _ = cv2.solvePnP( + POSE_MODEL_POINTS, + image_points, + camera_matrix, + np.zeros((4, 1), dtype=np.float64), + flags=cv2.SOLVEPNP_ITERATIVE, + ) + if not success: + return None + + rotation_matrix, _ = cv2.Rodrigues(rotation_vector) + yaw = degrees(asin(float(np.clip(rotation_matrix[0, 2], -1.0, 1.0)))) + pitch = degrees(atan2(-rotation_matrix[1, 2], rotation_matrix[2, 2])) + roll = degrees(atan2(-rotation_matrix[0, 1], rotation_matrix[0, 0])) + return {"pitch": pitch, "yaw": yaw, "roll": roll} + + +def estimate_gaze(landmarks: list[Any]) -> dict[str, Any]: + """Return a normalized horizontal iris ratio and a coarse direction.""" + required = (33, 133, 263, 362, 468, 473) + if len(landmarks) <= max(required): + return {"ratio": None, "direction": "unknown", "off_screen": False} + + left_inner = landmarks[133] + left_outer = landmarks[33] + right_inner = landmarks[362] + right_outer = landmarks[263] + left_iris = landmarks[473] + right_iris = landmarks[468] + + def ratio(iris: Any, inner: Any, outer: Any) -> float: + span = hypot(outer.x - inner.x, outer.y - inner.y) + return hypot(iris.x - inner.x, iris.y - inner.y) / span if span else 0.5 + + ratios = [ratio(left_iris, left_inner, left_outer), ratio(right_iris, right_inner, right_outer)] + average = sum(ratios) / len(ratios) + if average < 0.35: + direction = "left" + elif average > 0.65: + direction = "right" + else: + direction = "center" + return { + "ratio": round(average, 4), + "direction": direction, + "off_screen": direction != "center", + } diff --git a/server/services/weights/README.md b/server/services/weights/README.md new file mode 100644 index 0000000..d304e29 --- /dev/null +++ b/server/services/weights/README.md @@ -0,0 +1,10 @@ +# Model weights + +Place downloaded inference artifacts in this directory during local or image +build setup. Large binaries are intentionally ignored by Git. + +- `yolov8n.pt` — optional Ultralytics object detector weights. +- Additional model bundles may be added here as the worker configuration grows. + +The MediaPipe face landmarker bundle currently remains at +`server/services/face_landmarker.task` for backward compatibility. diff --git a/server/setup_database.py b/server/setup_database.py index f97f3da..ac28eaf 100644 --- a/server/setup_database.py +++ b/server/setup_database.py @@ -74,7 +74,7 @@ def seed_admin() -> None: sb = get_supabase() if sb is None: - print("Supabase client not configured. Set SUPABASE_URL and SUPABASE_KEY in .env") + print("Supabase client not configured. Set SUPABASE_URL and SUPABASE_SECRET_KEY in .env") sys.exit(1) res = sb.table("users").select("id").eq("username", "admin").execute() diff --git a/server/supabase_client.py b/server/supabase_client.py index 6ae35ef..f0f330e 100644 --- a/server/supabase_client.py +++ b/server/supabase_client.py @@ -3,12 +3,12 @@ Direct connection to Supabase using supabase-py """ -import os from supabase import create_client, Client from config import SUPABASE_URL, SUPABASE_KEY # Initialize Supabase client -# Ensure SUPABASE_URL and SUPABASE_KEY are provided in environment +# Ensure SUPABASE_URL and the server-only secret key are provided in the +# environment. The secret key must never be sent to the browser or extension. if not SUPABASE_URL or not SUPABASE_KEY: print("[WARN] Supabase credentials missing from environment!") supabase: Client = None diff --git a/server/workers/report_worker.py b/server/workers/report_worker.py index bc81b3b..33f2486 100644 --- a/server/workers/report_worker.py +++ b/server/workers/report_worker.py @@ -5,6 +5,7 @@ import sys import tempfile from datetime import datetime, timezone +from pathlib import Path _server_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if _server_dir not in sys.path: @@ -16,29 +17,27 @@ def _render_html(session: dict, events: list[dict]) -> str: - rows = "".join( - f"{e.get('created_at', '')}{e.get('type', '')}" - f"{e.get('weight', 0)}" - for e in events - ) - return f""" - - ExamGuard Report - -

ExamGuard Pro — Session Report

-

Session ID: {session.get('id')}

-

Risk Score: {session.get('risk_score', 0)} ({session.get('risk_level', 'safe')})

-

Generated: {datetime.now(timezone.utc).isoformat()}

-

Events

- {rows}
TimeTypeWeight
- - """ + template_path = Path(_server_dir) / "reports" / "templates" / "session_report.html" + generated_at = datetime.now(timezone.utc).isoformat() + try: + from jinja2 import Environment, FileSystemLoader, select_autoescape + + environment = Environment( + loader=FileSystemLoader(template_path.parent), + autoescape=select_autoescape(("html", "xml")), + ) + return environment.get_template(template_path.name).render( + session=session, + events=events, + generated_at=generated_at, + ) + except ImportError: + # Keep report generation usable in minimal installations. + return ( + "

ExamGuard Pro - Session Report

" + f"

Session ID: {session.get('id')}

" + f"

Generated: {generated_at}

" + ) @celery_app.task(name="workers.report_worker.generate_report", bind=True, max_retries=2) diff --git a/start.sh b/start.sh index 0bcafec..3fd6984 100644 --- a/start.sh +++ b/start.sh @@ -16,6 +16,7 @@ export PYTHONPATH=$PYTHONPATH:. mkdir -p /tmp/matplotlib_cache mkdir -p server/uploads/screenshots mkdir -p server/uploads/webcam +mkdir -p server/uploads/reports echo "=== Starting Uvicorn ===" # Using 'main:app' from root which redirects to 'server.main:app'