diff --git a/Cargo.toml b/Cargo.toml index 2fd4f8b..c53ce87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,10 +17,6 @@ path = "src/main.rs" name = "coursebank" path = "src/lib.rs" -[features] -default = ["parquet"] -parquet = ["dep:parquet", "dep:arrow-array", "dep:arrow-schema"] - [dependencies] clap = { version = "4", features = ["derive"] } csv = "1" @@ -29,9 +25,14 @@ serde_json = "1" serde_yaml_ng = "0.10" thiserror = "2" -arrow-array = { version = "55", optional = true } -arrow-schema = { version = "55", optional = true } -parquet = { version = "55", optional = true } +arrow-array = { version = "55"} +arrow-schema = { version = "55"} +parquet = { version = "55"} +aes-gcm = { version = "0.10"} +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"]} +sha2 = { version = "0.10"} +base64 = { version = "0.22"} +getrandom = { version = "0.2"} [profile.release] opt-level = 3 diff --git a/docs/guide/assignment.md b/docs/guide/assignment.md new file mode 100644 index 0000000..ee3509c --- /dev/null +++ b/docs/guide/assignment.md @@ -0,0 +1,192 @@ +# An assignment on the web + +This follows a single homework from an assembled record to a published Quarto page whose solutions stay locked until a student enters a password. +It assumes a course directory with approved items and an assembled assessment; if you do not have one, [`setup`](crate::guide::setup) and [`first_exam`](crate::guide::first_exam) build both. + +The site export is the third off-Canvas path, alongside the printed exam in [`typst_export`](crate::guide::typst_export) and the plain-text worksheet from `export practice`. +The difference is where the solutions go: printed on a key you keep, or encrypted into a bundle that ships with the page and unlocks in the browser. + +## Assemble the homework + +Assemble it the same way you assemble an exam, with the platform set to the website rather than paper or Canvas: + +```console +$ coursebank assemble a1.1 \ + --title "Homework 1" \ + --kind homework --platform other \ + --levels 2=1,3=1 --lectures L1.1 --seed 20260210 +``` + +What lands in `assessments/a1.1.yaml` is a record of the draw. +The id `a1.1` is the one thing to choose deliberately here: it becomes the bundle's file name and the name the page's gate points at, so keep it URL-safe and stable. + +A single unshuffled form is fine for homework. +To hand different students different option orders, add `--forms 2` and export each form separately; the printed choices and the letters the solutions refer to move together, because both come from the form's recorded seed. + +## Export for the web + +```console +$ coursebank export site a1.1 --out build/a1.1 --assets build/static +password for a1.1: k7m4-9p2q-r8tx-3wn6 +wrote build/a1.1/_questions.qmd +wrote build/a1.1/a1.1-solutions.json +wrote build/static/questions.css +wrote build/static/solutions.js + +Include in the page with: {{< include _questions.qmd >}} +``` + +That is four files and a password. + +`_questions.qmd` is the partial you include from the page. +`a1.1-solutions.json` is the encrypted bundle, named for the assessment so several assignments can share one site. +The two files under `build/static` style the questions and perform the unlock; they install once for the whole site, not once per page, so `--assets` is something you run the first time and drop afterward. + +The password is printed once and written nowhere. +Record it now. +It cannot be recovered from the files, which is the point: the bundle is useless without it, so losing it means re-exporting rather than reading it back. + +## What the partial holds + +The questions are Quarto fenced divs, with an empty, hidden slot where each solution will land: + +```text +::: {.solutions-gate data-bundle="a1.1-solutions.json"} +::: + +::: {.q #q-enthalpy-qp-001} +:::: {.q-head} +[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points} +:::: + +:::: {.q-stem} +A reaction is run in an open flask, so the system stays at the constant +pressure of the room. The heat the reaction exchanges with its surroundings +equals the change in which quantity? +:::: + +:::: {.q-choices} +1. Enthalpy, $\Delta H$ +2. Internal energy, $\Delta U$ +3. Gibbs free energy, $\Delta G$ +4. Entropy, $\Delta S$ +:::: + +:::: {.qsol data-solution-for="q-enthalpy-qp-001" hidden="true"} +:::: +::: +``` + +The choices are printed without letters, and the stylesheet draws the A, B, C, D from their position. +There is nothing in this file to leak: no `correct` flag, no solution text, no rationale. +The `.qsol` slot is empty until the browser fills it, and the id in `data-solution-for` is the key the bundle looks up. + +Math is written in `$ … $` and passed through untouched, so MathJax typesets it in the page. + +## What the bundle holds + +Every solution is rendered to HTML, then encrypted: + +```json +{ + "v": 1, + "page": "a1.1", + "kdf": { "name": "PBKDF2", "hash": "SHA-256", "iterations": 250000, "salt": "jqbE6xIB..." }, + "cipher": "AES-GCM", + "items": { + "q-enthalpy-qp-001": { "iv": "8jzuxY...", "ct": "gmzNSlhioU...(+tag)" }, + "q-enthalpy-derive-001": { "iv": "1BddCd...", "ct": "sC8czL547/...(+tag)" } + } +} +``` + +The password derives an AES-256 key through PBKDF2-HMAC-SHA256 at 250,000 iterations over a random salt. +Each solution is encrypted under its own random IV, with the authentication tag appended to the ciphertext. +The plaintext HTML never leaves your machine. +Items are listed in the order the questions appear, not sorted, so the reader's browser can decrypt the first one to check the password before touching the rest. + +## Wire it into the site + +Install the two assets once in `_quarto.yml`: + +```yaml +format: + html: + css: + - static/questions.css + include-after-body: + - static/solutions.js +``` + +Put `_questions.qmd` and `a1.1-solutions.json` in the page's own directory, and include the partial from the page: + +```markdown +--- +title: "Homework 1" +--- + +{{< include _questions.qmd >}} +``` + +The gate resolves `data-bundle` relative to the page URL, so keeping the bundle beside the page is enough. +If your build prunes files it does not see linked, add `resources: ["*-solutions.json"]` to the page front matter so the bundle ships with the render. + +Render with `quarto render`. +A reader who opens the page sees the questions and a locked panel; typing the password decrypts the solutions in place, with the math typeset. + +## Hand out the password, and rotate it + +Give the password through a channel students already have, such as the course LMS, rather than the site itself. + +Be clear-eyed about what the lock does. +It keeps solutions off a public page until someone has the password. +It does not make them secret in a strong sense: the whole bundle is downloaded, so anyone with the password, or anyone they share it with, can decrypt every item, and the ciphertext is open to an offline guessing attack. +Eighty bits of password entropy and a quarter-million PBKDF2 iterations make guessing slow, but the right mental model is a lock on a take-home worksheet, not a grading system of record. + +Rotate the password after the due date by re-running the export, which mints a fresh one and a freshly encrypted bundle: + +```console +$ coursebank export site a1.1 --out build/a1.1 +password for a1.1: 2h9k-w4rq-8mnp-x6tv +... +``` + +To set a password yourself instead of generating one, pass `--password`. +Use that only when you have a reason to, such as re-encrypting an unchanged page with a password you already circulated. + +## Doing this from Rust + +The CLI is a thin wrapper over `coursebank::site`, which is compiled only with the `site` feature. +The example below needs `--features site` to build, so it is not run as a doctest: + +```rust,ignore +use std::path::Path; + +use coursebank::assessment::{AssessmentFile, Form}; +use coursebank::site::{self, Options}; +use coursebank::Catalog; + +fn main() -> coursebank::Result<()> { + let catalog = Catalog::load(Path::new("."))?; + let path = catalog.layout.assessments().join("a1.1.yaml"); + let record = AssessmentFile::load(&path)?; + + // An unshuffled form A; declare a form with a seed to shuffle. + let form = Form { id: "A".into(), seed: 0, shuffle_items: false, shuffle_options: false }; + + // `password: None` generates a fresh one; pass `Some(_)` to set your own. + let rendered = site::render(&catalog, &record, Options { form, password: None })?; + + std::fs::write("build/a1.1/_questions.qmd", &rendered.questions_qmd)?; + std::fs::write( + format!("build/a1.1/{}-solutions.json", rendered.page), + &rendered.solutions_json, + )?; + // The password is the one thing not on disk; print it now. + println!("password for {}: {}", rendered.page, rendered.password); + + Ok(()) +} +``` + +`site::assets()` returns the two browser files as name and contents, if you would rather write them from your own code than pass `--assets`. diff --git a/src/assets/site/questions.css b/src/assets/site/questions.css new file mode 100644 index 0000000..77fd138 --- /dev/null +++ b/src/assets/site/questions.css @@ -0,0 +1,249 @@ +/* ============================================================================ + questions.css — worksheet items + gated solutions + Sits beside editor-notes.css: same left-border-accent language, same + small-caps auto-headers, same .quarto-dark dark-mode hook. + + Worksheet content is authored as Quarto MARKDOWN inside fenced divs, so + inline math is $ … $ and paragraphs/lists render normally. Choice letters + (A, B, C, D) are drawn by a CSS counter, so a choice is just a list item. + + Semantic color coding (information, not decoration): + indigo = a solution / answer region + green = the correct choice / accepted answer + red = a named misconception + ==========================================================================*/ + +:root { + --q-ink: #1f2328; + --q-muted: #5a5a5a; + --q-line: #e4e4e4; + --q-card-bg: #ffffff; + + --sol-accent: #4b4f9a; /* indigo: "here be answers" */ + --sol-bg: #f5f5fb; + --sol-rule: #dedcef; + + --ok-ink: #2f6249; /* correct / accepted (matches editorial green) */ + --ok-bg: #e7f2ec; + --mis-ink: #a13d2e; /* misconception (matches editorial red) */ +} + +/* ---- Question card ---- */ +.q { + border: 1px solid var(--q-line); + border-radius: 6px; + background: var(--q-card-bg); + padding: 1.1rem 1.3rem 1.25rem; + margin: 1.6rem 0; +} + +/* Header. Authored as [Question 1]{.q-num} [..]{.q-kind} [..]{.q-points}; */ +.q-head { + display: flex; + align-items: baseline; + gap: 0.75rem; + margin-bottom: 0.7rem; + padding-bottom: 0.55rem; + border-bottom: 1px solid var(--q-line); +} +.q-head > p { display: contents; margin: 0; } +.q-num { font-weight: 700; letter-spacing: 0.01em; } +.q-kind { + font-variant: small-caps; letter-spacing: 0.06em; + font-size: 0.78rem; color: var(--q-muted); +} +.q-points { + margin-left: auto; font-size: 0.8rem; color: var(--q-muted); + white-space: nowrap; +} + +.q-stem { margin: 0 0 0.9rem; } +.q-stem > p:first-child { margin-top: 0; } +.q-stem > p:last-child { margin-bottom: 0; } + +/* ---- Multiple choice (a plain ordered list; letters via counter) ---- */ +.q-choices > ol { + list-style: none; margin: 0; padding: 0; + display: grid; gap: 0.5rem; + counter-reset: choice; +} +.q-choices > ol > li { + position: relative; + padding: 0.55rem 0.7rem 0.55rem 3rem; /* room for the badge on the left */ + border: 1px solid var(--q-line); + border-radius: 5px; + counter-increment: choice; +} +.q-choices > ol > li::before { + content: counter(choice, upper-alpha); /* A, B, C, D … */ + position: absolute; + left: 0.55rem; top: 0.5rem; + display: grid; place-items: center; + width: 1.9rem; height: 1.9rem; + border: 1px solid var(--sol-accent); + border-radius: 50%; + font-weight: 700; font-size: 0.9rem; + color: var(--sol-accent); +} + +/* ---- Free-response writing space (prints with room to write) ----- */ +.q-response { + min-height: 6.5rem; + border: 1px dashed var(--q-line); + border-radius: 5px; + background: + repeating-linear-gradient( + to bottom, transparent, transparent 1.55rem, + var(--q-line) 1.55rem, var(--q-line) calc(1.55rem + 1px)); + background-position: 0 0.9rem; +} +.q-response::before { + content: "Your answer"; + display: block; + font-variant: small-caps; letter-spacing: 0.06em; + font-size: 0.72rem; color: var(--q-muted); + padding: 0.3rem 0.6rem 0; +} + +/* ---- Solution slot (filled by solutions.js on unlock) ---- */ +.qsol { + border-left: 3px solid var(--sol-accent); + border-radius: 0 4px 4px 0; + background: var(--sol-bg); + padding: 0.9rem 1.15rem; + margin-top: 1rem; + font-size: 0.95rem; line-height: 1.55; +} +.qsol::before { + content: "Solution"; + display: block; + font-variant: small-caps; letter-spacing: 0.06em; font-weight: 600; + color: var(--sol-accent); + padding-bottom: 0.35rem; margin-bottom: 0.6rem; + border-bottom: 1px solid var(--sol-rule); +} +.qsol > p:first-of-type { margin-top: 0; } +.qsol > *:last-child { margin-bottom: 0; } + +@keyframes sol-in { from { opacity: 0; transform: translateY(2px); } to { opacity: 1; } } +.qsol.is-unlocked { animation: sol-in 180ms ease-out; } +@media (prefers-reduced-motion: reduce) { .qsol.is-unlocked { animation: none; } } + +/* pieces inside a solution */ +.sol-answer { font-size: 1.02rem; } +.sol-model { + border-left: 2px solid var(--ok-ink); + background: var(--ok-bg); + padding: 0.55rem 0.8rem; border-radius: 0 4px 4px 0; margin: 0.7rem 0; +} +.sol-model > p:first-child { margin-top: 0; } +.sol-model > p:last-child { margin-bottom: 0; } +.sol-explain { margin: 0.7rem 0; } +.sol-feedback-title { + font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600; + color: var(--q-muted); margin: 0.9rem 0 0.4rem; +} + +/* per-distractor feedback: badge in col 1, BOTH text spans in col 2 ---- */ +.sol-feedback { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.6rem; } +.sol-feedback > li { + display: grid; + grid-template-columns: 1.9rem 1fr; /* badge | body */ + gap: 0.6rem; + align-items: start; +} +.sol-feedback .opt { + display: grid; place-items: center; width: 1.9rem; height: 1.9rem; + border-radius: 50%; font-weight: 700; font-size: 0.8rem; + color: var(--mis-ink); border: 1px solid var(--mis-ink); +} +.sol-feedback .opt-body { /* the single col-2 cell; fills 1fr */ + display: grid; gap: 0.25rem; +} +.sol-feedback .opt-mis { color: var(--mis-ink); font-style: italic; } +.sol-feedback .opt-why { color: var(--q-ink); } + +/* rubric table */ +.sol-rubric { width: 100%; border-collapse: collapse; margin: 0.7rem 0; font-size: 0.92rem; } +.sol-rubric caption { + text-align: left; font-variant: small-caps; letter-spacing: 0.05em; + font-weight: 600; color: var(--q-muted); padding-bottom: 0.35rem; +} +.sol-rubric th, .sol-rubric td { + border-top: 1px solid var(--sol-rule); padding: 0.4rem 0.55rem; text-align: left; + vertical-align: top; +} +.sol-rubric th:first-child, .sol-rubric td:first-child { + width: 2.5rem; text-align: center; font-weight: 700; color: var(--ok-ink); +} + +.sol-accepted { margin: 0.7rem 0; } +.sol-ref { margin-top: 0.7rem; font-size: 0.85rem; color: var(--q-muted); } + +/* badges */ +.badge { + display: inline-block; font-variant: small-caps; letter-spacing: 0.05em; + font-size: 0.72rem; font-weight: 700; padding: 0.05em 0.5em; + border-radius: 999px; margin-right: 0.35em; + background: var(--sol-rule); color: var(--sol-accent); +} +.badge-correct { background: var(--ok-bg); color: var(--ok-ink); } + +/* ---- The password gate --- */ +.solutions-gate { margin: 1.4rem 0; } +.gate-inner { + display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; + padding: 0.75rem 1rem; + border: 1px solid var(--sol-rule); border-left: 3px solid var(--sol-accent); + border-radius: 0 5px 5px 0; background: var(--sol-bg); +} +.gate-lock { + width: 0.8rem; height: 0.7rem; border: 2px solid var(--sol-accent); + border-radius: 2px; position: relative; flex: none; +} +.gate-lock::before { + content: ""; position: absolute; left: 50%; top: -0.42rem; transform: translateX(-50%); + width: 0.5rem; height: 0.42rem; border: 2px solid var(--sol-accent); + border-bottom: none; border-radius: 4px 4px 0 0; +} +.gate-lock.is-open::before { left: 20%; } +.gate-label { font-variant: small-caps; letter-spacing: 0.05em; font-weight: 600; color: var(--sol-accent); } +.gate-input { + flex: 1 1 12rem; min-width: 9rem; + padding: 0.4rem 0.6rem; border: 1px solid var(--sol-rule); + border-radius: 4px; background: var(--q-card-bg); color: var(--q-ink); +} +.gate-btn { + padding: 0.42rem 1rem; border: 1px solid var(--sol-accent); border-radius: 4px; + background: var(--sol-accent); color: #fff; font-weight: 600; cursor: pointer; +} +.gate-btn:hover { filter: brightness(1.08); } +.gate-btn:disabled { opacity: 0.6; cursor: progress; } +.gate-btn-ghost { background: transparent; color: var(--sol-accent); } +.gate-status { flex-basis: 100%; margin: 0; font-size: 0.85rem; color: var(--q-muted); } +.solutions-gate.is-error .gate-input { border-color: var(--mis-ink); } +.solutions-gate.is-error .gate-status { color: var(--mis-ink); } +.gate-input:focus-visible, .gate-btn:focus-visible { outline: 2px solid var(--sol-accent); outline-offset: 2px; } + +@media print { + .solutions-gate { display: none; } + .qsol[hidden] { display: none; } + .q { break-inside: avoid; border-color: #bbb; } +} + +/* ============================ Dark mode ================================= */ +.quarto-dark { + --q-ink: #dfe2e7; + --q-muted: #a7adb6; + --q-line: #333a44; + --q-card-bg: #1b1f26; + + --sol-accent: #9aa0e6; + --sol-bg: #20222e; + --sol-rule: #343755; + + --ok-ink: #9dd3b4; + --ok-bg: #1b241f; + --mis-ink: #e0a498; +} +.quarto-dark .gate-btn { color: #14161c; } diff --git a/src/assets/site/solutions.js b/src/assets/site/solutions.js new file mode 100644 index 0000000..40c5cf4 --- /dev/null +++ b/src/assets/site/solutions.js @@ -0,0 +1,160 @@ +/* ============================================================================ + * solutions.js — per-page, password-gated solutions for the course site. + * + * How a page opts in: + * 1. Put one gate element somewhere on the page: + *
+ * `data-bundle` is resolved relative to the page URL, so each page points + * at its own bundle and therefore has its own password. Nothing else is + * shared between pages. + * 2. For every gated item, leave an empty, hidden slot where the solution + * should appear: + * + * The id in data-solution-for must match a key in the bundle's `items`. + * + * What happens on unlock: + * The typed password is run through PBKDF2 (same params as the bundle's kdf + * block) to derive an AES-256-GCM key. The first item is decrypted as a + * probe: because GCM authenticates, a wrong password throws, and we report + * "wrong password" without revealing anything. On success every slot is + * filled, un-hidden, and MathJax re-typesets the injected math. + * + * The ciphertext ships in the page, so this stops a student + * from reading answers in "View source" or the Network tab, and a wrong + * password reveals nothing. Anyone who has the password can decrypt, and the + * bundle can be brute-forced offline against a weak password. Use a real, + * non-guessable per-page password, and rotate it if a key deadline has passed. + * ==========================================================================*/ + +(() => { + "use strict"; + + const b64ToBytes = (s) => + Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); + + async function deriveKey(password, kdf) { + const base = await crypto.subtle.importKey( + "raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt: b64ToBytes(kdf.salt), + iterations: kdf.iterations, hash: kdf.hash }, + base, { name: "AES-GCM", length: 256 }, false, ["decrypt"]); + } + + async function decryptItem(key, item) { + const pt = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: b64ToBytes(item.iv) }, key, b64ToBytes(item.ct)); + return new TextDecoder().decode(pt); + } + + function typeset(nodes) { + if (window.MathJax && typeof window.MathJax.typesetPromise === "function") { + window.MathJax.typesetPromise(nodes).catch(() => { /* leave as-is */ }); + } + } + + function buildGate(gate) { + gate.classList.add("is-locked"); + gate.innerHTML = ` +
+ + + + +

+
`; + return { + input: gate.querySelector(".gate-input"), + button: gate.querySelector(".gate-btn"), + status: gate.querySelector(".gate-status"), + }; + } + + async function unlock(gate, ui) { + const url = gate.getAttribute("data-bundle"); + const pw = ui.input.value; + if (!pw) { ui.input.focus(); return; } + + gate.classList.remove("is-error"); + ui.button.disabled = true; + ui.status.textContent = "Checking\u2026"; + + let bundle; + try { + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`bundle ${res.status}`); + bundle = await res.json(); + } catch (e) { + ui.button.disabled = false; + ui.status.textContent = "Could not load the solutions file for this page."; + return; + } + + let key; + try { + key = await deriveKey(pw, bundle.kdf); + // Probe with the first item so a wrong password fails before we touch DOM. + const firstId = Object.keys(bundle.items)[0]; + await decryptItem(key, bundle.items[firstId]); + } catch (e) { + gate.classList.add("is-error"); + ui.button.disabled = false; + ui.status.textContent = "That password didn\u2019t work. Try again."; + ui.input.select(); + return; + } + + const filled = []; + for (const slot of document.querySelectorAll("[data-solution-for]")) { + const id = slot.getAttribute("data-solution-for"); + const item = bundle.items[id]; + if (!item) continue; + try { + slot.innerHTML = await decryptItem(key, item); + slot.hidden = false; + slot.classList.add("is-unlocked"); + filled.push(slot); + } catch (e) { /* skip an item that fails; others still unlock */ } + } + typeset(filled); + + gate.classList.remove("is-locked"); + gate.classList.add("is-unlocked"); + gate.innerHTML = ` +
+ + Solutions unlocked + +
`; + gate.querySelector(".gate-btn").addEventListener("click", () => { + for (const s of filled) { s.hidden = true; s.classList.remove("is-unlocked"); } + buildAndWire(gate); // relock the UI; content stays in memory only + }); + } + + function buildAndWire(gate) { + const ui = buildGate(gate); + const go = () => unlock(gate, ui); + ui.button.addEventListener("click", go); + ui.input.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); }); + } + + function init() { + const gate = document.querySelector(".solutions-gate[data-bundle]"); + if (!gate) return; + if (!window.crypto || !crypto.subtle) { + gate.textContent = + "This browser can\u2019t decrypt solutions (no Web Crypto over http/file)."; + return; + } + buildAndWire(gate); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs index 9a6a9ab..959779e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -435,6 +435,30 @@ pub(crate) enum ExportCommand { #[arg(long)] no_answer_space: bool, }, + /// Write a Quarto questions partial and an encrypted, password-gated + /// solutions bundle for a course website. + /// + /// Writes `_questions.qmd` and `-solutions.json` into the output directory, + /// and prints a fresh password that the files do not store. + Site { + /// Assessment id. + id: String, + /// Which form's option order to print. + #[arg(long, default_value = "A")] + form: String, + /// Output directory; defaults to build/. Point it at the page's own + /// directory so the browser fetches the bundle beside the page. + #[arg(long)] + out: Option, + /// Encrypt with this password instead of a generated one. Use only to + /// re-encrypt a page with a known password. + #[arg(long)] + password: Option, + /// Also write questions.css and solutions.js into this directory, e.g. + /// the site's static assets folder. They install once, not per page. + #[arg(long, value_name = "DIR")] + assets: Option, + }, } #[derive(Debug, Subcommand)] diff --git a/src/commands/export.rs b/src/commands/export.rs index ed6eb69..41fd546 100644 --- a/src/commands/export.rs +++ b/src/commands/export.rs @@ -195,6 +195,55 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result { } Ok(Outcome::Ok) } + ExportCommand::Site { + id, + form, + out, + password, + assets, + } => { + { + use coursebank::site; + let record = load_record(&catalog, id)?; + let form = pick_form(&record, form)?; + let dir = out.clone().unwrap_or(build); + std::fs::create_dir_all(&dir)?; + + let rendered = site::render( + &catalog, + &record, + site::Options { + form, + password: password.clone(), + }, + )?; + + let qmd = dir.join("_questions.qmd"); + yaml::write_text(&qmd, &rendered.questions_qmd)?; + let json = dir.join(format!("{}-solutions.json", rendered.page)); + yaml::write_text(&json, &rendered.solutions_json)?; + + if let Some(asset_dir) = assets { + std::fs::create_dir_all(asset_dir)?; + for (name, body) in site::assets() { + let path = asset_dir.join(name); + yaml::write_text(&path, body)?; + if !cli.quiet { + println!("wrote {}", path.display()); + } + } + } + + // The password cannot be recovered from the files; always print it. + println!("password for {}: {}", rendered.page, rendered.password); + if !cli.quiet { + println!("wrote {}", qmd.display()); + println!("wrote {}", json.display()); + println!("\nInclude in the page with: {{{{< include _questions.qmd >}}}}"); + } + Ok(Outcome::Ok) + } + } } } diff --git a/src/data.rs b/src/data.rs index db5b7f2..1fb0417 100644 --- a/src/data.rs +++ b/src/data.rs @@ -29,5 +29,4 @@ pub mod canvas; pub mod gradescope; pub mod responses; pub mod store; -#[cfg(feature = "parquet")] pub mod store_parquet; diff --git a/src/data/store.rs b/src/data/store.rs index f843193..51cb41d 100644 --- a/src/data/store.rs +++ b/src/data/store.rs @@ -49,21 +49,14 @@ impl Format { /// /// Parquet when the `parquet` feature is on, CSV otherwise. pub fn preferred() -> Format { - #[cfg(feature = "parquet")] - { - Format::Parquet - } - #[cfg(not(feature = "parquet"))] - { - Format::Csv - } + Format::Parquet } /// Whether this format can be written by the current build. pub fn is_available(self) -> bool { match self { Format::Csv => true, - Format::Parquet => cfg!(feature = "parquet"), + Format::Parquet => true, } } @@ -386,21 +379,10 @@ fn read_csv(path: &Path) -> Result { /// /// * `path` - the destination. /// * `rows` - the rows. -/// -/// # Errors -/// -/// Returns [`Error::FeatureDisabled`] when the feature is off. -#[cfg(feature = "parquet")] fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> { crate::store_parquet::write(path, rows) } -/// Stub for builds without Parquet support. -#[cfg(not(feature = "parquet"))] -fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> { - Err(Error::FeatureDisabled("Parquet", "parquet")) -} - /// Reads flat responses from Parquet. /// /// # Arguments @@ -414,7 +396,6 @@ fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> { /// # Errors /// /// Returns [`Error::FeatureDisabled`] when the feature is off. -#[cfg(feature = "parquet")] fn read_parquet(path: &Path) -> Result { let rows = crate::store_parquet::read(path)?; let mut set = ResponseSet::new(); @@ -422,16 +403,6 @@ fn read_parquet(path: &Path) -> Result { Ok(set) } -/// Stub for builds without Parquet support. -#[cfg(not(feature = "parquet"))] -fn read_parquet(path: &Path) -> Result { - Err(Error::Other(format!( - "{} is a Parquet file, but this build has Parquet support compiled out; \ - rebuild with `--features parquet`, or re-ingest with `--format csv`", - path.display() - ))) -} - /// Makes an administration id usable as a file name. /// /// # Arguments diff --git a/src/export.rs b/src/export.rs index c799049..c8bd215 100644 --- a/src/export.rs +++ b/src/export.rs @@ -9,6 +9,7 @@ //! | [`qti`] | a QTI 1.2 zip | importing into Canvas | //! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet | //! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas | +//! | [`site`] | a Quarto partial and an encrypted bundle | a course page with password-gated solutions | //! | [`report`] | Markdown and HTML | students, and yourself | //! | [`lecture`] | Markdown | the reading list on the course website | //! @@ -26,4 +27,5 @@ pub mod lecture; pub mod practice; pub mod qti; pub mod report; +pub mod site; pub mod typst; diff --git a/src/export/qti.rs b/src/export/qti.rs index 4239c2c..c5e6251 100644 --- a/src/export/qti.rs +++ b/src/export/qti.rs @@ -47,9 +47,7 @@ const IMSMD_NS: &str = "http://www.imsglobal.org/xsd/imsmd_v1p2"; const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \ http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd"; -// --- // A very small XML tree -// --- /// One XML element. #[derive(Debug, Clone)] @@ -155,9 +153,7 @@ fn escape_attr(s: &str) -> String { escape_text(s).replace('"', """) } -// --- // Package construction -// --- /// Options for a QTI export. #[derive(Debug, Clone)] diff --git a/src/export/site.rs b/src/export/site.rs new file mode 100644 index 0000000..53639b6 --- /dev/null +++ b/src/export/site.rs @@ -0,0 +1,976 @@ +// SPDX-License-Identifier: Prosperity-3.0.0 +// Copyright Scientific Computing Studio +// Source: https://git.scient.ing/education/coursebank + +//! Rendering an assessment for a Quarto course website, with solutions gated +//! behind a per-page password. +//! +//! This is the path that publishes to the web rather than to Canvas or a printed +//! exam. It produces three things for one assessment: +//! +//! 1. A `_questions.qmd` partial: the questions as Quarto fenced divs, with an +//! empty, hidden `.qsol` slot per item. A page includes it with +//! `{{< include _questions.qmd >}}`. It carries no answers. +//! 2. A `-solutions.json` bundle: every solution rendered to an HTML fragment, +//! then encrypted. The ciphertext ships in the page, but the plaintext never +//! does, so a student cannot read answers from the source or the network tab. +//! 3. A password: a fresh, random, per-bundle password that decrypts the bundle. +//! It is printed for the instructor and stored nowhere, so it cannot be +//! recovered from the files. Hand it out, and rotate it after a due date. +//! +//! The browser side is [`assets`]: `questions.css` styles the questions and the +//! unlocked solutions, and `solutions.js` derives the key from the typed password, +//! decrypts, and injects each fragment. The crypto here matches that script byte +//! for byte: PBKDF2-HMAC-SHA256 at 250,000 iterations derives a 256-bit key, and +//! AES-256-GCM encrypts each fragment under a fresh 96-bit IV with the 128-bit tag +//! appended to the ciphertext. Get any parameter wrong and a correct password +//! would fail to authenticate. +//! +//! Two rules carry over from the other exporters. A question paper never contains +//! the answer: the `.qsol` slot is empty in the qmd and the answer lives only in +//! the encrypted bundle. And option order comes from the form's seed, so the +//! printed letters in the questions and the letters the solution refers to are the +//! same order. + +use crate::assessment::{AssessmentFile, Form, Placement}; +use crate::catalog::Catalog; +use crate::course::{CourseFile, Reference}; +use crate::error::{Error, Result}; +use crate::item::{Choice, Citation, Item, Solution}; +use crate::markup; +use crate::select; +use crate::taxonomy::Format; + +use aes_gcm::Aes256Gcm; +use aes_gcm::aead::generic_array::GenericArray; +use aes_gcm::aead::{Aead, KeyInit}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as B64; +use serde::ser::SerializeMap; +use serde::{Serialize, Serializer}; + +/// PBKDF2 iteration count. Must match `solutions.js` and `make_solutions.py`. +const ITERATIONS: u32 = 250_000; + +/// Crockford base32 without the ambiguous `i`, `l`, `o`, `u`. Thirty-two symbols +/// means five bits each, so sixteen of them carry eighty bits of entropy. +const PW_ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz"; + +/// The bundled browser assets, installed once per site (not per page). +/// +/// # Returns +/// +/// Pairs of file name and verbatim contents: the stylesheet and the unlock +/// script. Write them into the site's static directory and wire them in +/// `_quarto.yml`. +pub fn assets() -> [(&'static str, &'static str); 2] { + [ + ( + "questions.css", + include_str!("../assets/site/questions.css"), + ), + ("solutions.js", include_str!("../assets/site/solutions.js")), + ] +} + +/// How to render one assessment for the site. +#[derive(Debug, Clone)] +pub struct Options { + /// The form whose option order to print. + pub form: Form, + /// A password to encrypt with. `None` generates a fresh random one, which is + /// the intended path; pass `Some` only to re-encrypt a bundle with a known + /// password. + pub password: Option, +} + +impl Options { + /// Builds options for a form, generating the password. + /// + /// # Arguments + /// + /// * `form` - the form whose option order to print. + /// + /// # Returns + /// + /// Options that will generate a fresh password at render time. + pub fn new(form: Form) -> Options { + Options { + form, + password: None, + } + } +} + +/// Everything one render produces, ready to write. +#[derive(Debug, Clone)] +pub struct Rendered { + /// The assessment id, used for the bundle file name and the gate's + /// `data-bundle` attribute. + pub page: String, + /// The `_questions.qmd` partial. + pub questions_qmd: String, + /// The `-solutions.json` bundle, serialized and newline-terminated. + pub solutions_json: String, + /// The password that decrypts the bundle. Print it; it is stored nowhere. + pub password: String, +} + +/// Renders an assessment into the questions partial and the encrypted bundle. +/// +/// # Arguments +/// +/// * `catalog` - the loaded course, for items, objectives, and references. +/// * `record` - the assembled assessment. +/// * `opts` - the form and an optional password. +/// +/// # Returns +/// +/// The partial, the serialized bundle, and the password. +/// +/// # Errors +/// +/// Returns [`Error::Unresolved`] when a placement names an item the catalog does +/// not hold, and [`Error::Other`] on a CSPRNG, encryption, or serialization +/// failure. +pub fn render(catalog: &Catalog, record: &AssessmentFile, opts: Options) -> Result { + let page = record.assessment.id.clone(); + let questions_qmd = questions_qmd(catalog, record, &opts.form, &page)?; + let fragments = solution_fragments(catalog, record, &opts.form)?; + let password = match opts.password { + Some(p) => p, + None => gen_password()?, + }; + let bundle = build_bundle(&page, &password, &fragments)?; + let json = serde_json::to_string_pretty(&bundle) + .map_err(|e| Error::other(format!("could not serialize the solutions bundle: {e}")))?; + Ok(Rendered { + page, + questions_qmd, + solutions_json: format!("{json}\n"), + password, + }) +} + +/// Builds the `_questions.qmd` partial. +fn questions_qmd( + catalog: &Catalog, + record: &AssessmentFile, + form: &Form, + page: &str, +) -> Result { + let mut out = String::new(); + out.push_str( + "\n\n", + ); + out.push_str(&format!( + "::: {{.solutions-gate data-bundle=\"{page}-solutions.json\"}}\n:::\n\n" + )); + + let mut number = 0usize; + let default_points = catalog.course.policy.points_per_item; + let layout = select::layout(record, form); + for placement in layout.iter().filter(|p| !p.dropped) { + let item = &catalog.require(&placement.item)?.item; + number += 1; + out.push_str(&question_block( + number, + placement, + item, + form, + default_points, + )); + out.push('\n'); + } + + // Leave exactly one trailing newline. + while out.ends_with("\n\n") { + out.pop(); + } + Ok(out) +} + +/// One `.q` block: head, stem, choices or a writing box, then the empty slot. +fn question_block( + number: usize, + placement: &Placement, + item: &Item, + form: &Form, + default_points: f64, +) -> String { + let id = &item.id; + let points = placement + .points + .unwrap_or_else(|| item.points(default_points)); + let unit = if (points - 1.0).abs() < f64::EPSILON { + "point" + } else { + "points" + }; + + let mut b = String::new(); + b.push_str(&format!("::: {{.q #{id}}}\n")); + b.push_str(":::: {.q-head}\n"); + b.push_str(&format!( + "[Question {number}]{{.q-num}} [{}]{{.q-kind}} [{} {unit}]{{.q-points}}\n", + kind_label(item.format), + trim_number(points), + )); + b.push_str("::::\n\n"); + + b.push_str(":::: {.q-stem}\n"); + b.push_str(&markup::to_markdown(&item.stem)); + b.push_str("\n::::\n\n"); + + if item.has_options() { + b.push_str(":::: {.q-choices}\n"); + let order = select::option_order(form, &placement.item, item.options.len()); + for (position, &source) in order.iter().enumerate() { + b.push_str(&format!( + "{}. {}\n", + position + 1, + markup::to_markdown(&item.options[source].text) + )); + } + b.push_str("::::\n\n"); + } else { + b.push_str(":::: {.q-response aria-hidden=\"true\"}\n::::\n\n"); + } + + b.push_str(&format!( + ":::: {{.qsol data-solution-for=\"{id}\" hidden=\"true\"}}\n::::\n" + )); + b.push_str(":::\n"); + b +} + +/// The human label for a format, shown in the question head. +fn kind_label(format: Format) -> &'static str { + match format { + Format::SingleBestAnswer => "Single best answer", + Format::MultipleResponse => "Multiple response", + Format::TrueFalse => "True or false", + Format::OpenResponse => "Open response", + } +} + +// --- the solution fragments --- + +/// Renders each item's solution to an HTML fragment, in printed order. +/// +/// An item with nothing to show (an open-response question whose solution is +/// empty) is left out, so its slot simply never unlocks. +fn solution_fragments( + catalog: &Catalog, + record: &AssessmentFile, + form: &Form, +) -> Result> { + let mut out = Vec::new(); + let layout = select::layout(record, form); + for placement in layout.iter().filter(|p| !p.dropped) { + let item = &catalog.require(&placement.item)?.item; + if let Some(html) = fragment(&catalog.course, placement, item, form) { + out.push((item.id.clone(), html)); + } + } + Ok(out) +} + +/// The fragment for one item, or `None` when there is nothing to show. +fn fragment( + course: &CourseFile, + placement: &Placement, + item: &Item, + form: &Form, +) -> Option { + if item.has_options() { + Some(choice_fragment(course, placement, item, form)) + } else { + open_fragment(course, item) + } +} + +/// A single-best-answer or multiple-response fragment: the key, the model answer, +/// the explanation, then per-distractor feedback. +fn choice_fragment(course: &CourseFile, placement: &Placement, item: &Item, form: &Form) -> String { + let order = select::option_order(form, &placement.item, item.options.len()); + let printed: Vec<(usize, &Choice)> = order + .iter() + .enumerate() + .map(|(position, &source)| (position, &item.options[source])) + .collect(); + + let mut out = String::new(); + + let keyed: Vec = printed + .iter() + .filter(|(_, c)| c.correct) + .map(|(position, c)| { + format!( + "{} — {}", + letter(*position), + inline_html(&c.text) + ) + }) + .collect(); + out.push_str( + "

Correct answer ", + ); + out.push_str(&keyed.join("; ")); + out.push_str("

\n"); + + if let Some(solution) = item.solution.as_ref() { + if let Some(model) = &solution.model_answer { + out.push_str(&format!( + "
{}
\n", + block_html(model) + )); + } + if let Some(explanation) = &solution.explanation { + out.push_str(&format!( + "

{}

\n", + inline_html(explanation) + )); + } + } + + let distractors: Vec<(usize, &Choice)> = printed + .iter() + .copied() + .filter(|(_, c)| !c.correct) + .collect(); + if distractors + .iter() + .any(|(_, c)| c.misconception.is_some() || why_wrong(c).is_some()) + { + out.push_str("\n"); + out.push_str("
    \n"); + for (position, c) in &distractors { + if c.misconception.is_none() && why_wrong(c).is_none() { + continue; + } + out.push_str(&format!( + "
  • {}\n", + letter(*position) + )); + out.push_str("
    \n"); + if let Some(mis) = &c.misconception { + out.push_str(&format!( + " {}\n", + inline_html(mis) + )); + } + if let Some(why) = why_wrong(c) { + out.push_str(&format!( + " {}\n", + inline_html(why) + )); + } + out.push_str("
  • \n"); + } + out.push_str("
\n"); + } + + push_reference(&mut out, course, item.solution.as_ref()); + out +} + +/// An open-response fragment: the model answer, the rubric, accepted variants. +fn open_fragment(course: &CourseFile, item: &Item) -> Option { + let solution = item.solution.as_ref().filter(|s| !s.is_empty())?; + let mut out = String::new(); + + if let Some(model) = &solution.model_answer { + out.push_str(&format!( + "
{}
\n", + block_html(model) + )); + } + + if !solution.rubric.is_empty() { + let caption = match solution.rubric_points() { + Some(total) => { + let unit = if (total - 1.0).abs() < f64::EPSILON { + "point" + } else { + "points" + }; + format!("Rubric — {} {unit}", trim_number(total)) + } + None => "Rubric".to_string(), + }; + out.push_str("\n"); + out.push_str(&format!(" \n")); + out.push_str( + " \n", + ); + out.push_str(" \n"); + for criterion in &solution.rubric { + let pts = criterion.points.map(trim_number).unwrap_or_default(); + out.push_str(&format!( + " \n", + inline_html(&criterion.description) + )); + } + out.push_str(" \n
{caption}
PtsCriterion
{pts}{}
\n"); + } + + if !solution.accepted.is_empty() { + let joined = solution + .accepted + .iter() + .map(|a| inline_html(a)) + .collect::>() + .join("; "); + out.push_str(&format!( + "

Also accepted {joined}

\n" + )); + } + + push_reference(&mut out, course, Some(solution)); + Some(out) +} + +/// The student-facing reason a distractor is wrong: the instructor explanation +/// first, then any student feedback. +fn why_wrong(choice: &Choice) -> Option<&str> { + choice + .explanation + .as_deref() + .or(choice.feedback_student.as_deref()) +} + +/// Appends the `Source:` line when the solution carries review citations. +fn push_reference(out: &mut String, course: &CourseFile, solution: Option<&Solution>) { + let Some(solution) = solution else { return }; + if solution.review.is_empty() { + return; + } + let sources = solution + .review + .iter() + .map(|c| cite_html(course, c)) + .collect::>() + .join("; "); + out.push_str(&format!("

Source: {sources}

\n")); +} + +/// Renders one citation to HTML, linking it when a URL resolves. +fn cite_html(course: &CourseFile, citation: &Citation) -> String { + if let Some(text) = &citation.text { + if citation.reference.is_none() { + return markup::escape_html(text); + } + } + let Some(key) = &citation.reference else { + return markup::escape_html(&citation.display()); + }; + let Some(reference) = course.references.get(key) else { + return markup::escape_html(&citation.display()); + }; + let label = reference.label.as_deref().unwrap_or(key); + let locator = citation.locator.as_deref().unwrap_or(""); + let body = if locator.is_empty() { + markup::escape_html(label) + } else { + format!( + "{} {}", + markup::escape_html(label), + markup::escape_html(locator) + ) + }; + match resolve_url(citation, reference) { + Some(url) => format!("{body}", markup::escape_html(&url)), + None => body, + } +} + +/// Resolves a citation's link, from an explicit URL or a path joined to the +/// reference's base URL. +fn resolve_url(citation: &Citation, reference: &Reference) -> Option { + if let Some(url) = &citation.url { + return Some(url.clone()); + } + let path = citation.path.as_deref()?; + let base = reference.base_url.as_deref()?; + Some(match (base.ends_with('/'), path.starts_with('/')) { + (true, true) => format!("{base}{}", &path[1..]), + (false, false) => format!("{base}/{path}"), + _ => format!("{base}{path}"), + }) +} + +// --- math-aware markup --- + +/// One run of source text, split on math delimiters. +enum Segment<'a> { + /// Prose, formatted with the shared escape, symbol, and inline rules. + Text(&'a str), + /// A math span, passed through with its interior escaped for the browser. + Math { inner: &'a str, display: bool }, +} + +/// Splits source into prose and `$…$` or `$$…$$` math runs. +/// +/// The split runs on raw source so a formatting rule can never reach inside math: +/// an underscore in `$q_p$` is a subscript, not the start of an emphasis span. +fn split_math(src: &str) -> Vec> { + let mut segments = Vec::new(); + let mut rest = src; + while let Some(at) = rest.find('$') { + if at > 0 { + segments.push(Segment::Text(&rest[..at])); + } + let after = &rest[at..]; + if let Some(display) = after.strip_prefix("$$") { + if let Some(end) = display.find("$$") { + segments.push(Segment::Math { + inner: &display[..end], + display: true, + }); + rest = &display[end + 2..]; + continue; + } + } + let inline = &after[1..]; + match inline.find('$') { + Some(end) => { + segments.push(Segment::Math { + inner: &inline[..end], + display: false, + }); + rest = &inline[end + 1..]; + } + None => { + // An unterminated `$` is treated as ordinary text. + segments.push(Segment::Text(after)); + rest = ""; + } + } + } + if !rest.is_empty() { + segments.push(Segment::Text(rest)); + } + segments +} + +/// Formats a prose run: HTML-escape, then the symbol table and inline markup. +fn format_prose(text: &str) -> String { + markup::apply_inline(&markup::apply_symbols(&markup::escape_html(text), true)) +} + +/// Emits a math run with its delimiters, escaping the interior so the browser +/// hands MathJax clean text (a `<` inside math becomes `<`, which the DOM +/// decodes back before MathJax reads it). +fn render_math(inner: &str, display: bool) -> String { + let delim = if display { "$$" } else { "$" }; + format!("{delim}{}{delim}", markup::escape_html(inner)) +} + +/// Converts authoring markup to an inline HTML string, preserving math. +/// +/// No paragraph wrapping: the caller supplies the surrounding element. +fn inline_html(src: &str) -> String { + let mut out = String::new(); + for segment in split_math(src.trim()) { + match segment { + Segment::Text(text) => out.push_str(&format_prose(text)), + Segment::Math { inner, display } => out.push_str(&render_math(inner, display)), + } + } + out +} + +/// Like [`inline_html`], but wraps each blank-line-separated paragraph in `

`. +fn block_html(src: &str) -> String { + src.split("\n\n") + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(|p| format!("

{}

", inline_html(p))) + .collect::>() + .join("") +} + +// --- the encrypted bundle ----- + +/// The encrypted solutions bundle, matching the `solutions.js` v1 format. +#[derive(Debug, Clone, Serialize)] +pub struct Bundle { + v: u8, + page: String, + kdf: Kdf, + cipher: &'static str, + items: OrderedItems, +} + +#[derive(Debug, Clone, Serialize)] +struct Kdf { + name: &'static str, + hash: &'static str, + iterations: u32, + salt: String, +} + +#[derive(Debug, Clone, Serialize)] +struct Enc { + iv: String, + ct: String, +} + +/// Item entries serialized as a JSON object in insertion order, so the bundle +/// lists solutions in the order the questions appear rather than sorted by id. +#[derive(Debug, Clone)] +struct OrderedItems(Vec<(String, Enc)>); + +impl Serialize for OrderedItems { + fn serialize(&self, serializer: S) -> std::result::Result { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (id, enc) in &self.0 { + map.serialize_entry(id, enc)?; + } + map.end() + } +} + +/// Generates a random per-bundle password. +/// +/// Sixteen Crockford base32 symbols, grouped in fours, for eighty bits of entropy +/// from the system CSPRNG, for example `k7m4-9p2q-r8tx-3wn6`. +/// +/// # Returns +/// +/// The password, to print for the instructor. +/// +/// # Errors +/// +/// Returns [`Error::Other`] if the system CSPRNG is unavailable. +pub fn gen_password() -> Result { + let mut raw = [0u8; 16]; + csprng(&mut raw)?; + // Thirty-two divides 256, so the modulo is unbiased. + let symbols: Vec = raw + .iter() + .map(|b| PW_ALPHABET[(*b % 32) as usize]) + .collect(); + let groups: Vec = symbols + .chunks(4) + .map(|c| String::from_utf8_lossy(c).into_owned()) + .collect(); + Ok(groups.join("-")) +} + +/// Encrypts rendered fragments into a bundle. +/// +/// # Arguments +/// +/// * `page` - the assessment id, stored as `page` and echoed by the script. +/// * `password` - the password to derive the key from. +/// * `fragments` - item id and solution HTML, in the order to list them. +/// +/// # Returns +/// +/// The bundle, ready to serialize as `-solutions.json`. +/// +/// # Errors +/// +/// Returns [`Error::Other`] on a CSPRNG or encryption failure. +pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result { + let mut salt = [0u8; 16]; + csprng(&mut salt)?; + let key = derive_key(password, &salt); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|_| Error::other("the derived AES key was the wrong length"))?; + + let mut items = Vec::with_capacity(fragments.len()); + for (id, html) in fragments { + let mut iv = [0u8; 12]; + csprng(&mut iv)?; + let nonce = GenericArray::from_slice(&iv); + let ct = cipher + .encrypt(nonce, html.as_bytes()) + .map_err(|_| Error::other("AES-GCM encryption failed"))?; + items.push(( + id.clone(), + Enc { + iv: B64.encode(iv), + ct: B64.encode(ct), + }, + )); + } + + Ok(Bundle { + v: 1, + page: page.to_string(), + kdf: Kdf { + name: "PBKDF2", + hash: "SHA-256", + iterations: ITERATIONS, + salt: B64.encode(salt), + }, + cipher: "AES-GCM", + items: OrderedItems(items), + }) +} + +/// Derives the AES-256 key with PBKDF2-HMAC-SHA256. +fn derive_key(password: &str, salt: &[u8]) -> [u8; 32] { + let mut key = [0u8; 32]; + pbkdf2::pbkdf2_hmac::(password.as_bytes(), salt, ITERATIONS, &mut key); + key +} + +/// Fills a buffer with cryptographically secure random bytes. +fn csprng(buf: &mut [u8]) -> Result<()> { + getrandom::getrandom(buf).map_err(|e| Error::other(format!("system CSPRNG unavailable: {e}"))) +} + +// --- small helpers ------- + +/// Formats a point value: no decimal when whole, at most two places otherwise. +fn trim_number(value: f64) -> String { + if value.fract() == 0.0 { + format!("{}", value as i64) + } else { + let s = format!("{value:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + } +} + +/// The printed letter for a position: 0 is A, 1 is B, and so on. +fn letter(position: usize) -> char { + char::from(b'A' + (position % 26) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn form() -> Form { + Form { + id: "A".into(), + seed: 0, + shuffle_items: false, + shuffle_options: false, + } + } + + fn catalog(tag: &str) -> Catalog { + let dir = std::env::temp_dir().join(format!("cb-site-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("banks")).unwrap(); + std::fs::write( + dir.join("course.yaml"), + r#" +course: { code: BIOSC 1000, title: Biochemistry, term: 2026f } +references: + kkw: + label: KKW + title: The molecules of life + base_url: https://example.org/kkw/ +lectures: + L1.1: { title: Enthalpy } +learning_objectives: + lo-enthalpy: + text: Define enthalpy and explain the constant-pressure result. + lectures: [L1.1] + order: 1 +"#, + ) + .unwrap(); + std::fs::write( + dir.join("banks").join("b.yaml"), + r#" +bank: { id: b, title: Bank } +items: + - id: q-mcq + status: draft + level: 2 + format: single_best_answer + stem: "The heat at constant pressure equals a change in what?" + learning_objectives: [lo-enthalpy] + options: + - { id: A, text: "Enthalpy, $\\Delta H$", correct: true, feedback_student: "Right, $q_p = \\Delta H$." } + - { id: B, text: "Internal energy, $\\Delta U$", misconception: "Uses the constant-volume result.", explanation: "That holds only at constant volume." } + solution: + model_answer: "The change in enthalpy, $\\Delta H$." + review: + - { ref: kkw, locator: "§6.3" } + - id: q-open + status: draft + level: 3 + format: open_response + stem: "Show why $q_p = \\Delta H$." + learning_objectives: [lo-enthalpy] + solution: + model_answer: "From $H = U + PV$ at constant pressure, $q_p = \\Delta H$." + rubric: + - { description: "States $H = U + PV$.", points: 1 } + - { description: "Reaches $q_p = \\Delta H$.", points: 1 } + accepted: ["$q_p = \\Delta H$ via $H = U + PV$"] +"#, + ) + .unwrap(); + Catalog::load(&dir).expect("catalog loads") + } + + fn record() -> AssessmentFile { + use crate::assessment::{Assessment, Kind, Platform}; + AssessmentFile { + schema_version: "1.0".into(), + assessment: Assessment { + id: "a1.1".into(), + title: "Homework 1".into(), + term: None, + kind: Kind::Homework, + date: None, + platform: Platform::Other, + minutes_allowed: None, + attempts: None, + shuffle: None, + scoring_policy: None, + instructions: None, + notes: None, + }, + blueprint: None, + forms: Vec::new(), + items: vec![ + Placement { + number: 1, + item: "b::q-mcq".into(), + version: None, + fingerprint: None, + points: Some(1.0), + bonus: false, + key: vec!["A".into()], + level: None, + learning_objectives: Vec::new(), + credit_overrides: Default::default(), + dropped: false, + }, + Placement { + number: 2, + item: "b::q-open".into(), + version: None, + fingerprint: None, + points: Some(2.0), + bonus: false, + key: Vec::new(), + level: None, + learning_objectives: Vec::new(), + credit_overrides: Default::default(), + dropped: false, + }, + ], + } + } + + #[test] + fn questions_partial_has_no_front_matter_and_no_answers() { + let out = questions_qmd(&catalog("qmd"), &record(), &form(), "a1.1").unwrap(); + assert!(!out.starts_with("---"), "a partial carries no front matter"); + assert!(out.contains("::: {.solutions-gate data-bundle=\"a1.1-solutions.json\"}")); + assert!(out.contains("::: {.q #q-mcq}")); + assert!( + out.contains("[Question 1]{.q-num} [Single best answer]{.q-kind} [1 point]{.q-points}") + ); + // Choices are printed without letters; the correct flag never appears. + assert!(out.contains("1. Enthalpy, $\\Delta H$")); + assert!(!out.contains("correct")); + // The open-response question gets a writing box, both get an empty slot. + assert!(out.contains(":::: {.q-response aria-hidden=\"true\"}")); + assert!(out.contains(":::: {.qsol data-solution-for=\"q-mcq\" hidden=\"true\"}")); + assert!(out.contains("[2 points]{.q-points}")); + // No model answer leaks into the questions. + assert!(!out.contains("sol-model")); + } + + #[test] + fn a_choice_fragment_marks_the_key_and_explains_the_distractor() { + let cat = catalog("choice"); + let rec = record(); + let frags = solution_fragments(&cat, &rec, &form()).unwrap(); + let mcq = &frags.iter().find(|(id, _)| id == "q-mcq").unwrap().1; + assert!(mcq.contains("Correct answer")); + assert!(mcq.contains("A — Enthalpy, $\\Delta H$")); + assert!(mcq.contains( + "

The change in enthalpy, $\\Delta H$.

" + )); + assert!(mcq.contains("B")); + assert!(mcq.contains("Uses the constant-volume result.")); + assert!(mcq.contains("That holds only at constant volume.")); + assert!(mcq.contains("

Source: KKW §6.3

")); + } + + #[test] + fn an_open_fragment_has_a_rubric_table_with_a_total() { + let cat = catalog("open"); + let rec = record(); + let frags = solution_fragments(&cat, &rec, &form()).unwrap(); + let open = &frags.iter().find(|(id, _)| id == "q-open").unwrap().1; + assert!(open.contains("Rubric — 2 points")); + assert!(open.contains("1States $H = U + PV$.")); + assert!(open.contains("Also accepted")); + } + + #[test] + fn inline_html_keeps_math_verbatim_and_escapes_prose() { + // A subscript inside math survives; angle brackets outside math are escaped. + assert_eq!(inline_html("value $q_p$ < 5"), "value $q_p$ < 5"); + // Bold outside math becomes a tag; a dollar-math run is passed through. + assert_eq!( + inline_html("**H** is $H = U + PV$"), + "H is $H = U + PV$" + ); + } + + #[test] + fn the_bundle_round_trips_through_the_kdf_and_cipher() { + use aes_gcm::aead::generic_array::GenericArray; + let fragments = vec![ + ("q-mcq".to_string(), "

alpha

".to_string()), + ("q-open".to_string(), "

beta

".to_string()), + ]; + let bundle = build_bundle("a1.1", "enthalpy2026", &fragments).unwrap(); + assert_eq!(bundle.v, 1); + assert_eq!(bundle.page, "a1.1"); + assert_eq!(bundle.cipher, "AES-GCM"); + assert_eq!(bundle.kdf.iterations, ITERATIONS); + // Insertion order is preserved in the serialized object. + let json = serde_json::to_string(&bundle).unwrap(); + assert!(json.find("q-mcq").unwrap() < json.find("q-open").unwrap()); + + // Decrypt the first item the way the browser would and check the plaintext. + let salt = B64.decode(&bundle.kdf.salt).unwrap(); + let key = derive_key("enthalpy2026", &salt); + let cipher = Aes256Gcm::new_from_slice(&key).unwrap(); + let (_, enc) = &bundle.items.0[0]; + let iv = B64.decode(&enc.iv).unwrap(); + let ct = B64.decode(&enc.ct).unwrap(); + let pt = cipher + .decrypt(GenericArray::from_slice(&iv), ct.as_ref()) + .unwrap(); + assert_eq!(String::from_utf8(pt).unwrap(), "

alpha

"); + } + + #[test] + fn a_generated_password_has_the_expected_shape() { + let pw = gen_password().unwrap(); + assert_eq!(pw.len(), 19); // 16 symbols + 3 dashes + assert_eq!(pw.matches('-').count(), 3); + assert!( + pw.chars() + .all(|c| c == '-' || PW_ALPHABET.contains(&(c as u8))) + ); + } + + #[test] + fn the_two_browser_assets_are_bundled() { + let assets = assets(); + assert_eq!(assets[0].0, "questions.css"); + assert_eq!(assets[1].0, "solutions.js"); + assert!(assets[0].1.contains(".qsol")); + assert!(assets[1].1.contains("AES-GCM")); + } +} diff --git a/src/guide.rs b/src/guide.rs index b0b99a8..490a0aa 100644 --- a/src/guide.rs +++ b/src/guide.rs @@ -21,7 +21,9 @@ //! 3. [`first_exam`] runs one exam end to end: assemble, export, administer, //! ingest, analyze, report. //! 4. [`typst_export`] covers printed output, template markers, and render config. -//! 5. [`recipes`] holds short answers to specific questions, for when you already +//! 5. [`assignment`] publishes a homework to the course website, with solutions +//! gated behind a password. +//! 6. [`recipes`] holds short answers to specific questions, for when you already //! know the shape of the tool. //! //! ## Why the tutorials are in here rather than a wiki @@ -55,6 +57,10 @@ pub mod first_exam {} #[doc = include_str!("../docs/TYPST.md")] pub mod typst_export {} +/// Publishing an assignment to the website, with solutions gated behind a password. +#[doc = include_str!("../docs/guide/assignment.md")] +pub mod assignment {} + /// Short answers to specific questions. #[doc = include_str!("../docs/guide/recipes.md")] pub mod recipes {} diff --git a/src/lib.rs b/src/lib.rs index 766de78..4782d67 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -104,12 +104,12 @@ pub use model::{assessment, bank, catalog, course, history, item, layout, taxono pub use authoring::{jsonschema, lint, select}; -#[cfg(feature = "parquet")] pub use data::store_parquet; pub use data::{canvas, gradescope, responses, store}; pub use analysis::{calibrate, classical, irt, students}; +pub use export::site; pub use export::{lecture, practice, qti, report, typst}; pub use catalog::Catalog; diff --git a/src/model/bank.rs b/src/model/bank.rs index a09b58d..9efd32f 100644 --- a/src/model/bank.rs +++ b/src/model/bank.rs @@ -352,7 +352,7 @@ fn validate_item( issues.push("version must be at least 1".into()); } - // --- options --- + // --- options ---- // An open-response item takes no options; its answer lives in `solution`. // Every other format needs at least two things to choose between. if it.format.has_options() { @@ -428,7 +428,7 @@ fn validate_item( } } - // --- key ----- + // --- key --- let keys = it.key_indices(); match it.format { Format::SingleBestAnswer => { @@ -465,7 +465,7 @@ fn validate_item( } } - // --- level and process must agree -------- + // --- level and process must agree ------- if let Some(p) = it.cognitive_process { if !it.level.allows(p) { issues.push(format!( @@ -476,7 +476,7 @@ fn validate_item( } } - // --- design plausibility ----------- + // --- design plausibility ------ if let Some(d) = &it.design { if let Some(x) = d.expected_difficulty { if !(0.0..=1.0).contains(&x) { @@ -548,7 +548,7 @@ fn validate_item( )); } - // --- retirement ---- + // --- retirement ----- if it.retired.is_some() && it.status != Status::Retired { issues.push(format!( "has a `retired` block but status is `{}`", @@ -588,7 +588,7 @@ fn validate_item( } } - // --- cross-file references --------- + // --- cross-file references ---- if let Some(c) = course { for lo in &it.learning_objectives { match c.learning_objectives.get(lo) { diff --git a/src/util/markup.rs b/src/util/markup.rs index 2a32663..ea99217 100644 --- a/src/util/markup.rs +++ b/src/util/markup.rs @@ -199,7 +199,7 @@ pub fn escape_html(s: &str) -> String { /// # Returns /// /// The substituted text. -fn apply_symbols(s: &str, html: bool) -> String { +pub(crate) fn apply_symbols(s: &str, html: bool) -> String { let mut out = s.to_string(); for (token, entity, plain) in SYMBOLS { if out.contains(token) { @@ -218,7 +218,7 @@ fn apply_symbols(s: &str, html: bool) -> String { /// # Returns /// /// The text with inline markup converted. -fn apply_inline(s: &str) -> String { +pub(crate) fn apply_inline(s: &str) -> String { let mut out = s.to_string(); // Bracketed forms first: their contents may contain other markup characters. out = wrap_bracket(&out, "#sub[", "", "");