Skip to content

Align project structure and Supabase runtime - #2

Merged
veeda241 merged 1 commit into
mainfrom
veeda241-structure-project
Sep 12, 2026
Merged

veeda241 merged 1 commit into
mainfrom
veeda241-structure-project

Conversation

@veeda241

@veeda241 veeda241 commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Why

The repository structure and local/runtime configuration had drifted from the documented ExamGuard Pro architecture. Telemetry capture routes, gaze estimation, report templates, model asset boundaries, and current Supabase key configuration needed to be represented consistently across development and deployment.

What changed

  • Added authenticated screenshot, webcam, legacy analysis upload, and latest-frame endpoints.
  • Added the gaze/head-pose service and connected it to MediaPipe face analysis.
  • Added Jinja2 session report templates and model-weight documentation without committing large model binaries.
  • Updated Supabase configuration to support publishable, secret, and JWKS variables while preserving the legacy key alias.
  • Updated Render configuration, setup scripts, Windows startup scripts, README guidance, and frontend lockfile.
  • Added the server/services/weights and server/reports/templates structure and ensured report upload directories are created at startup.

Validation

  • Backend tests: 16 passed.
  • Frontend tests: 3 passed.
  • Frontend production build completed successfully.
  • Python compilation and git diff --check passed.

Notes

  • Supabase secret and Redis credentials remain environment-only; no credentials are committed.
  • Windows Celery development should use the configured Redis URL and --pool=solo when required by local multiprocessing permissions.

Summary by Sourcery

Align the project structure and runtime configuration with the current ExamGuard Pro architecture while adding telemetry ingestion, gaze analysis, and templated reporting.

New Features:

  • Add authenticated screenshot, webcam, combined analysis, and latest-frame telemetry endpoints.
  • Add MediaPipe-based head-pose and coarse gaze estimation to face analysis.
  • Introduce Jinja2 session report templates and documented model-weight storage boundaries.

Bug Fixes:

  • Ensure upload and report directories are created when the application starts.
  • Preserve compatibility with legacy Supabase key configuration while using current server-side key names.

Enhancements:

  • Align local development, deployment, documentation, and Windows startup configuration with the current backend structure and runtime environment.
  • Support Supabase publishable, secret, and JWKS configuration without committing credentials.

Build:

  • Update frontend dependency lockfile and runtime setup guidance.

Deployment:

  • Update Render environment configuration to use environment-managed Supabase and database credentials.

Documentation:

  • Document telemetry upload routes, model artifact locations, Supabase configuration, and local worker setup.

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>
Copilot AI lite review requested due to automatic review settings September 12, 2026 02:11
@sourcery-ai

sourcery-ai Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

This PR aligns the documented and deployed architecture by adding authenticated telemetry APIs, integrating gaze/head-pose analysis, templating reports, formalizing model and upload directories, and migrating Supabase/runtime configuration to the current environment-variable scheme while preserving legacy compatibility.

Sequence diagram for authenticated telemetry upload and frame retrieval

sequenceDiagram
    actor User
    participant API
    participant Storage
    participant SessionWS
    participant Supabase

    User->>API: upload_screenshot(upload)
    API->>API: get_current_user()
    API->>Storage: _save_image(upload, kind)
    API->>SessionWS: publish_session_message(session_id, telemetry_frame)
    API-->>User: accepted

    User->>API: latest_frame(session_id, type)
    API->>API: get_current_user()
    API->>Supabase: select sessions
    Supabase-->>API: session ownership
    API-->>User: FileResponse(latest frame)
Loading

Sequence diagram for MediaPipe gaze and head-pose analysis

sequenceDiagram
    participant FaceDetection
    participant MediaPipe
    participant GazeTracking

    FaceDetection->>MediaPipe: face_landmarks
    FaceDetection->>GazeTracking: estimate_head_pose(face_landmarks, width, height)
    GazeTracking-->>FaceDetection: pose
    FaceDetection->>GazeTracking: estimate_gaze(face_landmarks)
    GazeTracking-->>FaceDetection: direction, ratio, off_screen
    FaceDetection-->>FaceDetection: store pose and gaze results
Loading

File-Level Changes

Change Details Files
Added authenticated telemetry capture and retrieval APIs while preserving the legacy combined analysis contract.
  • Registered screenshot, webcam, latest-frame, and legacy analysis routes.
  • Validated base64 image data, stored frames by session/type, and published telemetry events.
  • Applied session existence and student ownership checks when a database is available.
server/api/router_v2.py
server/api/uploads.py
Integrated gaze direction and head-pose estimation into MediaPipe face analysis.
  • Added OpenCV solvePnP-based pitch, yaw, and roll estimation.
  • Added iris-ratio-based coarse gaze classification.
  • Exposed gaze helpers through the services package and attached results to face-analysis output.
server/services/gaze_tracking.py
server/services/face_detection.py
server/services/__init__.py
Moved report rendering to a Jinja2 template and formalized model artifact boundaries.
  • Added the session report template with HTML autoescaping.
  • Added the ignored weights directory documentation.
  • Retained the MediaPipe bundle in its compatibility location.
server/reports/templates/session_report.html
server/services/weights/README.md
server/workers/report_worker.py
server/requirements.txt
README.md
Modernized Supabase configuration and removed committed deployment credentials.
  • Added publishable, secret, and JWKS settings while retaining SUPABASE_KEY fallback compatibility.
  • Updated setup guidance, schema comments, client usage, and Render environment declarations.
  • Changed local startup scripts to create/use server/.env and preserve configured Redis credentials.
server/config.py
server/supabase_client.py
server/setup_database.py
server/migrations/supabase_schema.sql
render.yaml
scripts/setup-supabase.ps1
scripts/start-dev.ps1
scripts/start-worker.ps1
README.md
Aligned runtime setup and dependency metadata with the new repository structure.
  • Created the reports upload directory during startup.
  • Updated frontend lockfile and deployment/setup documentation.
  • Documented local telemetry storage, runtime model placement, and Windows Celery behavior.
start.sh
examguard-pro/package-lock.json
README.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues

Fixed security issues:

  • render.yaml (link)
Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="server/api/uploads.py" line_range="69-76" />
<code_context>
+    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")
+
+
</code_context>
<issue_to_address>
**🚨 issue (security):** The upload endpoints never verify that the authenticated user owns `session_id`, so any authenticated user can write screenshot or webcam frames into another user's session; the latest-frame endpoint only performs the ownership check when a database client is available and therefore also permits cross-session access in database-less deployments.

**Triggers:** When a user submits a valid token while naming another user's session ID, or when Supabase is unavailable.

**Suggested fix:** Resolve the session and enforce ownership for every upload and retrieval, rejecting the request when the session cannot be verified rather than bypassing authorization when `db` is false.
</issue_to_address>

### Comment 2
<location path="server/api/uploads.py" line_range="50-54" />
<code_context>
+    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
+
</code_context>
<issue_to_address>
**🚨 issue (security):** `session_id` is interpolated directly into filesystem paths and glob patterns without validation, allowing path separators and `..` components to escape the telemetry directory; uploads can write outside the intended directory and latest-frame queries can enumerate and return files outside it.

**Triggers:** When an authenticated request supplies a session ID containing path traversal components.

**Suggested fix:** Validate session IDs against the application's identifier format and resolve the resulting path, rejecting any path that is not contained by the configured telemetry directory.
</issue_to_address>

### Comment 3
<location path="server/api/uploads.py" line_range="59-66" />
<code_context>
+
+
+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}
</code_context>
<issue_to_address>
**issue (bug_risk):** The image is written successfully before Redis publishing occurs, but a Redis connection or publish failure propagates out of the request; the client receives an error even though the frame remains on disk, creating orphaned telemetry files and preventing the documented upload acknowledgement.

**Triggers:** When Redis is unavailable or the publish operation fails after the filesystem write succeeds.

**Suggested fix:** Handle publish failure explicitly, either removing the saved file on failure or returning an accepted response while dispatching/retrying publication asynchronously.

```suggestion
    path = _save_image(upload, kind)
    try:
        publish_session_message(upload.session_id, {
            "type": "telemetry_frame",
            "session_id": upload.session_id,
            "kind": kind,
            "path": path.name,
        })
    except Exception:
        path.unlink(missing_ok=True)
        raise
    return {"status": "accepted", "session_id": upload.session_id, "filename": path.name}
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨

Comment thread server/api/uploads.py
Comment on lines +69 to +76
@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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): The upload endpoints never verify that the authenticated user owns session_id, so any authenticated user can write screenshot or webcam frames into another user's session; the latest-frame endpoint only performs the ownership check when a database client is available and therefore also permits cross-session access in database-less deployments.

Triggers: When a user submits a valid token while naming another user's session ID, or when Supabase is unavailable.

Suggested fix: Resolve the session and enforce ownership for every upload and retrieval, rejecting the request when the session cannot be verified rather than bypassing authorization when db is false.

Comment thread server/api/uploads.py
Comment on lines +50 to +54
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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 issue (security): session_id is interpolated directly into filesystem paths and glob patterns without validation, allowing path separators and .. components to escape the telemetry directory; uploads can write outside the intended directory and latest-frame queries can enumerate and return files outside it.

Triggers: When an authenticated request supplies a session ID containing path traversal components.

Suggested fix: Validate session IDs against the application's identifier format and resolve the resulting path, rejecting any path that is not contained by the configured telemetry directory.

Comment thread server/api/uploads.py
Comment on lines +59 to +66
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}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): The image is written successfully before Redis publishing occurs, but a Redis connection or publish failure propagates out of the request; the client receives an error even though the frame remains on disk, creating orphaned telemetry files and preventing the documented upload acknowledgement.

Triggers: When Redis is unavailable or the publish operation fails after the filesystem write succeeds.

Suggested fix: Handle publish failure explicitly, either removing the saved file on failure or returning an accepted response while dispatching/retrying publication asynchronously.

Suggested change
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}
path = _save_image(upload, kind)
try:
publish_session_message(upload.session_id, {
"type": "telemetry_frame",
"session_id": upload.session_id,
"kind": kind,
"path": path.name,
})
except Exception:
path.unlink(missing_ok=True)
raise
return {"status": "accepted", "session_id": upload.session_id, "filename": path.name}

@veeda241
veeda241 merged commit 37e52a3 into main Sep 12, 2026
5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved critical security, authentication, runtime, and telemetry integration issues require fixes and human review.

Pull request overview

Aligns the project structure and Supabase runtime configuration while adding telemetry ingestion, gaze analysis, and templated reporting.

Changes:

  • Added telemetry upload, latest-frame, gaze, and head-pose functionality.
  • Added Jinja2 reporting and model artifact documentation.
  • Updated configuration, startup scripts, deployment, documentation, and frontend dependencies.
File summaries
File Description
start.sh Creates report upload directories.
server/workers/report_worker.py Renders reports through Jinja2 templates.
server/supabase_client.py Uses server-only Supabase credentials.
server/setup_database.py Updates Supabase setup guidance.
server/services/weights/README.md Documents model artifact storage.
server/services/gaze_tracking.py Adds gaze and head-pose estimation.
server/services/face_detection.py Integrates gaze analysis with face detection.
server/services/__init__.py Exposes gaze helpers.
server/requirements.txt Adds Jinja2.
server/reports/templates/session_report.html Adds the session report template.
server/migrations/supabase_schema.sql Updates Supabase key guidance.
server/config.py Adds current Supabase configuration variables.
server/api/uploads.py Adds telemetry upload and latest-frame APIs.
server/api/router_v2.py Registers upload routes.
server/.env.example Documents runtime environment variables.
scripts/start-worker.ps1 Loads worker environment settings.
scripts/start-dev.ps1 Uses the server environment template.
scripts/setup-supabase.ps1 Updates setup instructions.
render.yaml Updates managed deployment variables.
README.md Updates architecture and setup documentation.
examguard-pro/package-lock.json Synchronizes frontend dependencies.
Review details

Files not reviewed (1)

  • examguard-pro/package-lock.json: Generated file

Suppressed comments (9)

README.md:112

  • The new README repeats server/services/weights/ as the active model location, but object detection still reads server/models/weights/yolo11s.pt and the build downloads into that other directory. This makes the setup instructions ineffective; align the README with the loader and build path.
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.

scripts/start-dev.ps1:15

  • This copies server/.env.example verbatim, but the new example contains active placeholder Supabase and PostgreSQL values. On first startup, Settings therefore initializes a fake Supabase client and constructs a bogus PostgreSQL URL instead of using the local fallback, so API/worker calls fail until the file is manually edited; keep optional integration values commented or stop auto-copying the template.
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

scripts/start-worker.ps1:39

  • Because server/.env.example defines CELERY_BROKER_URL and CELERY_RESULT_BACKEND as localhost, the loader sets them before this block. If a managed environment supplies only REDIS_URL, these variables are already non-empty and never inherit it, so the worker connects to localhost despite the configured Redis URL. Skip these keys while loading or treat the local defaults as unset.
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

server/.env.example:8

  • start-dev.ps1 copies this file verbatim and immediately starts the API. These uncommented placeholders make SUPABASE_URL/key non-empty, so supabase_client initializes an invalid client instead of using the no-Supabase fallback; the template's fake PG_HOST has the same problem for DB setup. Keep optional values commented/empty or make startup validate them before copying.
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

server/api/uploads.py:62

  • _store_and_publish trusts the client-supplied session_id and is called by both upload routes and the legacy route without loading the session or checking ownership. A student can therefore post telemetry into any known session, contaminating its stored frames and session broadcast; enforce the same student/proctor authorization used by the sessions and events APIs before saving.
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,

server/api/uploads.py:65

  • The published message is telemetry_frame with only a filename in path, but the dashboard's SessionDetail handler only consumes live_frame messages with frame_type and frame data. Upload notifications are therefore ignored and liveFrames never updates; the UI falls back to the separately protected URL. Emit the established message shape or update the frontend and URL handling together.
    publish_session_message(upload.session_id, {
        "type": "telemetry_frame",
        "session_id": upload.session_id,
        "kind": kind,
        "path": path.name,
    })

server/api/uploads.py:112

  • latest_frame is protected by HTTPBearer, but the dashboard uses this endpoint as an <img src> and its token is only attached by the Axios client. Browser image requests cannot send that Bearer header, so the fallback feed URLs return 401. Use a short-lived signed URL or fetch the image through the authenticated API client and create a blob URL.
@router.get("/latest/{session_id}")
async def latest_frame(
    session_id: str,
    type: str = "screenshot",
    user=Depends(get_current_user),

server/api/uploads.py:54

  • Every accepted frame is written as a new local file, but no retention or cleanup removes these files; the existing cleanup worker only scrubs database screenshot references. With the extension's periodic captures, per-session disk usage grows indefinitely and local Render storage is not durable. Bound retention/overwrite or persist frames through object storage before relying on this in production.
    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)

server/services/gaze_tracking.py:15

  • 199 is not the MediaPipe chin landmark; the chin is landmark 152. Because POSE_MODEL_POINTS[1] is the chin coordinate, this mismatched correspondence makes solvePnP fit the wrong facial point and produces incorrect pitch/yaw/roll values. Use landmark 152 here.
POSE_LANDMARKS = (1, 199, 33, 263, 61, 291)
  • Files reviewed: 20/21 changed files
  • Comments generated: 8
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread server/api/uploads.py
class ImageUpload(BaseModel):
session_id: str
timestamp: int | None = None
image_data: str = Field(min_length=16)
Comment thread server/api/uploads.py
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}"
Comment thread server/api/uploads.py
Comment on lines +117 to +121
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"]:
Comment thread server/api/uploads.py

async def _store_and_publish(upload: ImageUpload, kind: str) -> dict:
path = _save_image(upload, kind)
publish_session_message(upload.session_id, {
Comment thread server/api/uploads.py
Comment on lines +69 to +71
@router.post("/screenshot")
async def upload_screenshot(upload: ImageUpload, user=Depends(get_current_user)):
return await _store_and_publish(upload, "screenshot")
Comment thread server/api/uploads.py
Comment on lines +81 to +85
"""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.
"""
Comment on lines +80 to +81
left_iris = landmarks[473]
right_iris = landmarks[468]
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants