7.6 KiB
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 and first_exam build both.
The site export is the third off-Canvas path, alongside the printed exam in 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:
$ 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
$ 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:
::: {.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:
{
"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:
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:
---
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:
$ 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:
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.