diff --git a/README.md b/README.md index e1ee155..7340276 100644 --- a/README.md +++ b/README.md @@ -1,45 +1,77 @@ -# Live In-Lecture Quiz Portal +# Live in-lecture quiz portal -FastAPI, SQLite, WebSocket, and vanilla frontend implementation for a live classroom quiz. +FastAPI + WebSocket + SQLite quiz portal designed for ~40 students per +class session. Single-process, in-memory room manager, vanilla HTML/JS +front-end, Caddy in front for TLS. -## Install +## Quick local run ```bash -cd /home/ameer/RD/Projects/Apps/quiz python3 -m venv .venv . .venv/bin/activate pip install -e '.[dev]' -cp .env.example .env -``` - -Edit `.env` and set real values for `QUIZ_SECRET_KEY` and `QUIZ_ADMIN_PASSWORD`. - -## Run - -```bash -. .venv/bin/activate +cp .env.example .env # edit QUIZ_SECRET_KEY + QUIZ_ADMIN_PASSWORD uvicorn app.main:app --host 127.0.0.1 --port 8001 --reload ``` -Open `http://127.0.0.1:8001/admin/`, log in, create a quiz pool, then create a session. Use the displayed join URL in another browser or private window for the student view. +Open `http://127.0.0.1:8001/admin/`, log in, create a quiz pool from a +JSON pool file (see `examples/pool_example.json` for the schema), create +a session, and share the join URL. -## Test +## VPS deploy (one-shot) + +On a fresh Ubuntu 24.04 LTS root SSH: + +```bash +curl -fsSL https://gitea.ahkhan.me/apps/quiz/raw/branch/master/deploy/bootstrap.sh | bash +``` + +The bootstrap: +1. apt-installs Caddy + Python venv tooling +2. Creates a `quiz` system user (no shell, no SSH) +3. Clones this repo to `/opt/quiz` +4. Builds the venv and installs the app +5. Generates `QUIZ_SECRET_KEY`, prompts for `QUIZ_ADMIN_PASSWORD` +6. Drops the systemd unit and Caddyfile +7. Starts both services +8. Curl-checks `127.0.0.1:8001/healthz` + +After: `quiz.ahkhan.me` is live with auto-Let's-Encrypt cert. To override +the domain or repo URL, set `DOMAIN=` or `REPO_URL=` in the environment +before running the script. + +## Class-day workflow + +1. Provision Aliyun Intl HK ECS pay-as-you-go (`ecs.t6-c2m1.large`, + Ubuntu 24.04 LTS). +2. Point DNS A-record `quiz.ahkhan.me` at the new IP. +3. SSH in as root, run the curl|bash one-liner above. +4. Open `quiz.ahkhan.me/admin/`, log in, upload the week's pool JSON, + create a session. +5. Share the QR / join URL with the class. +6. After class: + `scp root@:/opt/quiz/quiz.db ./backups/quiz-YYYY-MM-DD.db` +7. Destroy the instance. + +## Quiz pool files + +Real pool JSON files contain answer keys and **must not be committed** +to this repo. `.gitignore` excludes `examples/*_pool.json` (only +`examples/pool_example.json` may be tracked). Author pools elsewhere +(e.g., your course-material directory) and upload at runtime via the +admin UI. + +## Tests ```bash -. .venv/bin/activate pytest -q pytest --cov=app ``` -The load simulation test creates 50 student WebSocket clients and runs a 5-question quiz. +For the WebSocket adversarial stress harness (Node.js + Playwright, +runs in a tmux loop), see `tests/stress/README.md`. -## Manual Smoke Test +## Spec -```bash -export QUIZ_DB_PATH=/tmp/quiz-smoke.db QUIZ_SECRET_KEY=smoke-secret QUIZ_ADMIN_PASSWORD=smoke-pass QUIZ_PUBLIC_URL=http://127.0.0.1:8001 -. .venv/bin/activate -uvicorn app.main:app --host 127.0.0.1 --port 8001 -curl http://127.0.0.1:8001/healthz -``` - -Expected health response starts with `{"ok":true`. +`SPEC.md` documents the locked v1.0 design (state machine, scoring, +identity flow, all WS message types). diff --git a/deploy/Caddyfile.tpl b/deploy/Caddyfile.tpl new file mode 100644 index 0000000..fa7a61f --- /dev/null +++ b/deploy/Caddyfile.tpl @@ -0,0 +1,4 @@ +__DOMAIN__ { + encode gzip + reverse_proxy 127.0.0.1:8001 +} diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh new file mode 100755 index 0000000..8ca1aac --- /dev/null +++ b/deploy/bootstrap.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Live in-lecture quiz portal — VPS bootstrap. +# Idempotent: safe to re-run on a partially-bootstrapped host. +# Designed for: fresh Ubuntu 24.04 LTS, run as root. +# +# Usage (one-shot, on the VPS): +# curl -fsSL https://gitea.ahkhan.me/apps/quiz/raw/branch/master/deploy/bootstrap.sh | bash +# +# Override via env: +# DOMAIN=quiz.example.org curl ... | bash +# REPO_URL=https://... curl ... | bash + +set -euo pipefail + +# When invoked through curl|bash, stdin is the pipe, not the TTY. +# Reattach TTY so `read -s` works for the password prompt. +[ -t 0 ] || exec < /dev/tty + +REPO_URL="${REPO_URL:-https://gitea.ahkhan.me/apps/quiz.git}" +APP_DIR="${APP_DIR:-/opt/quiz}" +APP_USER="${APP_USER:-quiz}" +DOMAIN="${DOMAIN:-quiz.ahkhan.me}" +BRANCH="${BRANCH:-master}" + +if [ "$(id -u)" != "0" ]; then + echo "bootstrap.sh must run as root" >&2 + exit 1 +fi + +stage() { printf '\n==> Stage %s\n' "$*"; } + +stage "1/8: apt update + base packages" +apt-get update -q +DEBIAN_FRONTEND=noninteractive apt-get install -y -q \ + git curl ca-certificates gnupg \ + python3 python3-venv python3-pip \ + debian-keyring debian-archive-keyring apt-transport-https + +stage "2/8: install Caddy (skip if present)" +if ! command -v caddy >/dev/null 2>&1; then + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ + | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg + curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ + | tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null + apt-get update -q + apt-get install -y -q caddy +fi + +stage "3/8: create $APP_USER system user (skip if present)" +if ! id "$APP_USER" >/dev/null 2>&1; then + useradd --system --shell /usr/sbin/nologin --home-dir "$APP_DIR" "$APP_USER" +fi + +stage "4/8: clone or update repo into $APP_DIR" +if [ -d "$APP_DIR/.git" ]; then + git -C "$APP_DIR" fetch origin + git -C "$APP_DIR" reset --hard "origin/$BRANCH" +else + rm -rf "$APP_DIR" + git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR" +fi +chown -R "$APP_USER":"$APP_USER" "$APP_DIR" + +stage "5/8: build venv + install dependencies" +sudo -u "$APP_USER" -H python3 -m venv "$APP_DIR/.venv" +sudo -u "$APP_USER" -H "$APP_DIR/.venv/bin/pip" install --quiet --upgrade pip +sudo -u "$APP_USER" -H "$APP_DIR/.venv/bin/pip" install --quiet -e "$APP_DIR" + +stage "6/8: configure environment (.env)" +ENV_FILE="$APP_DIR/.env" +if [ ! -f "$ENV_FILE" ]; then + if [ -f /root/.quiz.env ]; then + echo "Using /root/.quiz.env" + cp /root/.quiz.env "$ENV_FILE" + else + QUIZ_SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))') + printf 'Admin password (input hidden): ' + read -rs QUIZ_ADMIN_PASSWORD + echo + cat > "$ENV_FILE" < /etc/caddy/Caddyfile +systemctl reload caddy + +echo +echo "==> Health check" +sleep 2 +if curl -fs http://127.0.0.1:8001/healthz; then + echo + echo + echo "Bootstrap complete. Public URL: https://$DOMAIN" +else + echo + echo "Health check failed. Inspect: journalctl -u quiz.service -n 50" + exit 1 +fi diff --git a/deploy/quiz.service b/deploy/quiz.service new file mode 100644 index 0000000..9fdf191 --- /dev/null +++ b/deploy/quiz.service @@ -0,0 +1,21 @@ +[Unit] +Description=Live in-lecture quiz portal (uvicorn) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=quiz +Group=quiz +WorkingDirectory=/opt/quiz +EnvironmentFile=/opt/quiz/.env +ExecStart=/opt/quiz/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8001 --no-access-log +Restart=on-failure +RestartSec=2 +ProtectSystem=full +ProtectHome=true +PrivateTmp=true +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target diff --git a/examples/pool_example.json b/examples/pool_example.json new file mode 100644 index 0000000..cba5aee --- /dev/null +++ b/examples/pool_example.json @@ -0,0 +1,31 @@ +{ + "title": "Demo Pool: Generic Knowledge", + "score_fn": "linear_decay", + "time_limit_default": 60, + "questions": [ + { + "id": "demo1", + "text": "Which of these is a programming language?", + "options": { + "A": "HTTP", + "B": "Python", + "C": "TCP", + "D": "DNS" + }, + "correct": "B", + "explanation": "Python is a general-purpose programming language; the others are network protocols." + }, + { + "id": "demo2", + "text": "What is 2 + 2?", + "options": { + "A": "3", + "B": "4", + "C": "5", + "D": "22" + }, + "correct": "B", + "explanation": "Basic arithmetic." + } + ] +}