feat: write quarto assignment with encryption

This commit is contained in:
2026-08-10 03:25:15 -04:00
parent 327ac371e4
commit 1cc8137be9
15 changed files with 1678 additions and 53 deletions
+8 -7
View File
@@ -17,10 +17,6 @@ path = "src/main.rs"
name = "coursebank" name = "coursebank"
path = "src/lib.rs" path = "src/lib.rs"
[features]
default = ["parquet"]
parquet = ["dep:parquet", "dep:arrow-array", "dep:arrow-schema"]
[dependencies] [dependencies]
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
csv = "1" csv = "1"
@@ -29,9 +25,14 @@ serde_json = "1"
serde_yaml_ng = "0.10" serde_yaml_ng = "0.10"
thiserror = "2" thiserror = "2"
arrow-array = { version = "55", optional = true } arrow-array = { version = "55"}
arrow-schema = { version = "55", optional = true } arrow-schema = { version = "55"}
parquet = { version = "55", optional = true } 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] [profile.release]
opt-level = 3 opt-level = 3
+192
View File
@@ -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`.
+249
View File
@@ -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; }
+160
View File
@@ -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:
* <div class="solutions-gate" data-bundle="a1.1-solutions.json"></div>
* `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:
* <div class="solution" data-solution-for="q-enthalpy-qp-001" hidden></div>
* 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 = `
<div class="gate-inner">
<span class="gate-lock" aria-hidden="true"></span>
<label class="gate-label" for="gate-pw">Solutions are locked</label>
<input class="gate-input" id="gate-pw" type="password"
autocomplete="off" spellcheck="false"
placeholder="Enter the page password" />
<button class="gate-btn" type="button">Unlock</button>
<p class="gate-status" role="status" aria-live="polite"></p>
</div>`;
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 = `
<div class="gate-inner">
<span class="gate-lock is-open" aria-hidden="true"></span>
<span class="gate-label">Solutions unlocked</span>
<button class="gate-btn gate-btn-ghost" type="button">Hide again</button>
</div>`;
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();
}
})();
+24
View File
@@ -435,6 +435,30 @@ pub(crate) enum ExportCommand {
#[arg(long)] #[arg(long)]
no_answer_space: bool, no_answer_space: bool,
}, },
/// Write a Quarto questions partial and an encrypted, password-gated
/// solutions bundle for a course website.
///
/// Writes `_questions.qmd` and `<id>-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<PathBuf>,
/// Encrypt with this password instead of a generated one. Use only to
/// re-encrypt a page with a known password.
#[arg(long)]
password: Option<String>,
/// 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<PathBuf>,
},
} }
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
+49
View File
@@ -195,6 +195,55 @@ pub(crate) fn export(cli: &Cli, sub: &ExportCommand) -> Result<Outcome> {
} }
Ok(Outcome::Ok) 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)
}
}
} }
} }
-1
View File
@@ -29,5 +29,4 @@ pub mod canvas;
pub mod gradescope; pub mod gradescope;
pub mod responses; pub mod responses;
pub mod store; pub mod store;
#[cfg(feature = "parquet")]
pub mod store_parquet; pub mod store_parquet;
+2 -31
View File
@@ -49,21 +49,14 @@ impl Format {
/// ///
/// Parquet when the `parquet` feature is on, CSV otherwise. /// Parquet when the `parquet` feature is on, CSV otherwise.
pub fn preferred() -> Format { pub fn preferred() -> Format {
#[cfg(feature = "parquet")] Format::Parquet
{
Format::Parquet
}
#[cfg(not(feature = "parquet"))]
{
Format::Csv
}
} }
/// Whether this format can be written by the current build. /// Whether this format can be written by the current build.
pub fn is_available(self) -> bool { pub fn is_available(self) -> bool {
match self { match self {
Format::Csv => true, Format::Csv => true,
Format::Parquet => cfg!(feature = "parquet"), Format::Parquet => true,
} }
} }
@@ -386,21 +379,10 @@ fn read_csv(path: &Path) -> Result<ResponseSet> {
/// ///
/// * `path` - the destination. /// * `path` - the destination.
/// * `rows` - the rows. /// * `rows` - the rows.
///
/// # Errors
///
/// Returns [`Error::FeatureDisabled`] when the feature is off.
#[cfg(feature = "parquet")]
fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> { fn write_parquet(path: &Path, rows: &[FlatResponse]) -> Result<()> {
crate::store_parquet::write(path, rows) 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. /// Reads flat responses from Parquet.
/// ///
/// # Arguments /// # Arguments
@@ -414,7 +396,6 @@ fn write_parquet(_path: &Path, _rows: &[FlatResponse]) -> Result<()> {
/// # Errors /// # Errors
/// ///
/// Returns [`Error::FeatureDisabled`] when the feature is off. /// Returns [`Error::FeatureDisabled`] when the feature is off.
#[cfg(feature = "parquet")]
fn read_parquet(path: &Path) -> Result<ResponseSet> { fn read_parquet(path: &Path) -> Result<ResponseSet> {
let rows = crate::store_parquet::read(path)?; let rows = crate::store_parquet::read(path)?;
let mut set = ResponseSet::new(); let mut set = ResponseSet::new();
@@ -422,16 +403,6 @@ fn read_parquet(path: &Path) -> Result<ResponseSet> {
Ok(set) Ok(set)
} }
/// Stub for builds without Parquet support.
#[cfg(not(feature = "parquet"))]
fn read_parquet(path: &Path) -> Result<ResponseSet> {
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. /// Makes an administration id usable as a file name.
/// ///
/// # Arguments /// # Arguments
+2
View File
@@ -9,6 +9,7 @@
//! | [`qti`] | a QTI 1.2 zip | importing into Canvas | //! | [`qti`] | a QTI 1.2 zip | importing into Canvas |
//! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet | //! | [`typst`] | `.typ` source | a printed exam, answer key, and bubble sheet |
//! | [`practice`] | Quarto Markdown | a worksheet and a solutions document, off Canvas | //! | [`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 | //! | [`report`] | Markdown and HTML | students, and yourself |
//! | [`lecture`] | Markdown | the reading list on the course website | //! | [`lecture`] | Markdown | the reading list on the course website |
//! //!
@@ -26,4 +27,5 @@ pub mod lecture;
pub mod practice; pub mod practice;
pub mod qti; pub mod qti;
pub mod report; pub mod report;
pub mod site;
pub mod typst; pub mod typst;
-4
View File
@@ -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 \ const IMSCP_SCHEMA: &str = "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd \
http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd"; http://www.imsglobal.org/xsd/imsmd_v1p2 imsmd_v1p2p2.xsd";
// ---
// A very small XML tree // A very small XML tree
// ---
/// One XML element. /// One XML element.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -155,9 +153,7 @@ fn escape_attr(s: &str) -> String {
escape_text(s).replace('"', "&quot;") escape_text(s).replace('"', "&quot;")
} }
// ---
// Package construction // Package construction
// ---
/// Options for a QTI export. /// Options for a QTI export.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
+976
View File
@@ -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 `<id>-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<String>,
}
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 `<id>-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<Rendered> {
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<String> {
let mut out = String::new();
out.push_str(
"<!-- _questions.qmd — generated by `coursebank export site`. No YAML front\n\
\x20 matter, no title, no prose. Include from the page with:\n\
\x20 {{< include _questions.qmd >}} -->\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<Vec<(String, String)>> {
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<String> {
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<String> = printed
.iter()
.filter(|(_, c)| c.correct)
.map(|(position, c)| {
format!(
"<b>{}</b> &mdash; {}",
letter(*position),
inline_html(&c.text)
)
})
.collect();
out.push_str(
"<p class=\"sol-answer\"><span class=\"badge badge-correct\">Correct answer</span> ",
);
out.push_str(&keyed.join("; "));
out.push_str("</p>\n");
if let Some(solution) = item.solution.as_ref() {
if let Some(model) = &solution.model_answer {
out.push_str(&format!(
"<div class=\"sol-model\">{}</div>\n",
block_html(model)
));
}
if let Some(explanation) = &solution.explanation {
out.push_str(&format!(
"<p class=\"sol-explain\">{}</p>\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("<p class=\"sol-feedback-title\">Why the other options miss</p>\n");
out.push_str("<ul class=\"sol-feedback\">\n");
for (position, c) in &distractors {
if c.misconception.is_none() && why_wrong(c).is_none() {
continue;
}
out.push_str(&format!(
" <li><span class=\"opt\">{}</span>\n",
letter(*position)
));
out.push_str(" <div class=\"opt-body\">\n");
if let Some(mis) = &c.misconception {
out.push_str(&format!(
" <span class=\"opt-mis\">{}</span>\n",
inline_html(mis)
));
}
if let Some(why) = why_wrong(c) {
out.push_str(&format!(
" <span class=\"opt-why\">{}</span>\n",
inline_html(why)
));
}
out.push_str(" </div></li>\n");
}
out.push_str("</ul>\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<String> {
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!(
"<div class=\"sol-model\">{}</div>\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 &mdash; {} {unit}", trim_number(total))
}
None => "Rubric".to_string(),
};
out.push_str("<table class=\"sol-rubric\">\n");
out.push_str(&format!(" <caption>{caption}</caption>\n"));
out.push_str(
" <thead><tr><th scope=\"col\">Pts</th><th scope=\"col\">Criterion</th></tr></thead>\n",
);
out.push_str(" <tbody>\n");
for criterion in &solution.rubric {
let pts = criterion.points.map(trim_number).unwrap_or_default();
out.push_str(&format!(
" <tr><td>{pts}</td><td>{}</td></tr>\n",
inline_html(&criterion.description)
));
}
out.push_str(" </tbody>\n</table>\n");
}
if !solution.accepted.is_empty() {
let joined = solution
.accepted
.iter()
.map(|a| inline_html(a))
.collect::<Vec<_>>()
.join("; ");
out.push_str(&format!(
"<p class=\"sol-accepted\"><span class=\"badge\">Also accepted</span> {joined}</p>\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::<Vec<_>>()
.join("; ");
out.push_str(&format!("<p class=\"sol-ref\">Source: {sources}</p>\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!("<a href=\"{}\">{body}</a>", 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<String> {
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<Segment<'_>> {
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 `&lt;`, 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 `<p>`.
fn block_html(src: &str) -> String {
src.split("\n\n")
.map(str::trim)
.filter(|p| !p.is_empty())
.map(|p| format!("<p>{}</p>", inline_html(p)))
.collect::<Vec<_>>()
.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<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
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<String> {
let mut raw = [0u8; 16];
csprng(&mut raw)?;
// Thirty-two divides 256, so the modulo is unbiased.
let symbols: Vec<u8> = raw
.iter()
.map(|b| PW_ALPHABET[(*b % 32) as usize])
.collect();
let groups: Vec<String> = 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 `<page>-solutions.json`.
///
/// # Errors
///
/// Returns [`Error::Other`] on a CSPRNG or encryption failure.
pub fn build_bundle(page: &str, password: &str, fragments: &[(String, String)]) -> Result<Bundle> {
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::<sha2::Sha256>(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("<span class=\"badge badge-correct\">Correct answer</span>"));
assert!(mcq.contains("<b>A</b> &mdash; Enthalpy, $\\Delta H$"));
assert!(mcq.contains(
"<div class=\"sol-model\"><p>The change in enthalpy, $\\Delta H$.</p></div>"
));
assert!(mcq.contains("<span class=\"opt\">B</span>"));
assert!(mcq.contains("<span class=\"opt-mis\">Uses the constant-volume result.</span>"));
assert!(mcq.contains("<span class=\"opt-why\">That holds only at constant volume.</span>"));
assert!(mcq.contains("<p class=\"sol-ref\">Source: KKW §6.3</p>"));
}
#[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("<caption>Rubric &mdash; 2 points</caption>"));
assert!(open.contains("<td>1</td><td>States $H = U + PV$.</td>"));
assert!(open.contains("<span class=\"badge\">Also accepted</span>"));
}
#[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$ &lt; 5");
// Bold outside math becomes a tag; a dollar-math run is passed through.
assert_eq!(
inline_html("**H** is $H = U + PV$"),
"<strong>H</strong> 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(), "<p>alpha</p>".to_string()),
("q-open".to_string(), "<p>beta</p>".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(), "<p>alpha</p>");
}
#[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"));
}
}
+7 -1
View File
@@ -21,7 +21,9 @@
//! 3. [`first_exam`] runs one exam end to end: assemble, export, administer, //! 3. [`first_exam`] runs one exam end to end: assemble, export, administer,
//! ingest, analyze, report. //! ingest, analyze, report.
//! 4. [`typst_export`] covers printed output, template markers, and render config. //! 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. //! know the shape of the tool.
//! //!
//! ## Why the tutorials are in here rather than a wiki //! ## Why the tutorials are in here rather than a wiki
@@ -55,6 +57,10 @@ pub mod first_exam {}
#[doc = include_str!("../docs/TYPST.md")] #[doc = include_str!("../docs/TYPST.md")]
pub mod typst_export {} 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. /// Short answers to specific questions.
#[doc = include_str!("../docs/guide/recipes.md")] #[doc = include_str!("../docs/guide/recipes.md")]
pub mod recipes {} pub mod recipes {}
+1 -1
View File
@@ -104,12 +104,12 @@ pub use model::{assessment, bank, catalog, course, history, item, layout, taxono
pub use authoring::{jsonschema, lint, select}; pub use authoring::{jsonschema, lint, select};
#[cfg(feature = "parquet")]
pub use data::store_parquet; pub use data::store_parquet;
pub use data::{canvas, gradescope, responses, store}; pub use data::{canvas, gradescope, responses, store};
pub use analysis::{calibrate, classical, irt, students}; pub use analysis::{calibrate, classical, irt, students};
pub use export::site;
pub use export::{lecture, practice, qti, report, typst}; pub use export::{lecture, practice, qti, report, typst};
pub use catalog::Catalog; pub use catalog::Catalog;
+6 -6
View File
@@ -352,7 +352,7 @@ fn validate_item(
issues.push("version must be at least 1".into()); issues.push("version must be at least 1".into());
} }
// --- options --- // --- options ----
// An open-response item takes no options; its answer lives in `solution`. // An open-response item takes no options; its answer lives in `solution`.
// Every other format needs at least two things to choose between. // Every other format needs at least two things to choose between.
if it.format.has_options() { if it.format.has_options() {
@@ -428,7 +428,7 @@ fn validate_item(
} }
} }
// --- key ----- // --- key ---
let keys = it.key_indices(); let keys = it.key_indices();
match it.format { match it.format {
Format::SingleBestAnswer => { 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 let Some(p) = it.cognitive_process {
if !it.level.allows(p) { if !it.level.allows(p) {
issues.push(format!( issues.push(format!(
@@ -476,7 +476,7 @@ fn validate_item(
} }
} }
// --- design plausibility ----------- // --- design plausibility ------
if let Some(d) = &it.design { if let Some(d) = &it.design {
if let Some(x) = d.expected_difficulty { if let Some(x) = d.expected_difficulty {
if !(0.0..=1.0).contains(&x) { 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 { if it.retired.is_some() && it.status != Status::Retired {
issues.push(format!( issues.push(format!(
"has a `retired` block but status is `{}`", "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 { if let Some(c) = course {
for lo in &it.learning_objectives { for lo in &it.learning_objectives {
match c.learning_objectives.get(lo) { match c.learning_objectives.get(lo) {
+2 -2
View File
@@ -199,7 +199,7 @@ pub fn escape_html(s: &str) -> String {
/// # Returns /// # Returns
/// ///
/// The substituted text. /// 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(); let mut out = s.to_string();
for (token, entity, plain) in SYMBOLS { for (token, entity, plain) in SYMBOLS {
if out.contains(token) { if out.contains(token) {
@@ -218,7 +218,7 @@ fn apply_symbols(s: &str, html: bool) -> String {
/// # Returns /// # Returns
/// ///
/// The text with inline markup converted. /// 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(); let mut out = s.to_string();
// Bracketed forms first: their contents may contain other markup characters. // Bracketed forms first: their contents may contain other markup characters.
out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>"); out = wrap_bracket(&out, "#sub[", "<sub>", "</sub>");