Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .secrets.baseline

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,9 @@ lighthouse student attempt forget COURSE_ID QUIZ_ID --yes --json
```

`start` also returns the quiz's name, whether it is forward-only, its
attempt limit and its time limit in minutes (`time_limit_minutes`). Answers use the ids `page` returns: a choice id for a
attempt limit and its time limit in minutes (`time_limit_minutes`). Before a
quiz opens, `start` starts nothing and says when it opens (with `--json`, the
error's `opens_at` in UTC and `seconds_until_open`). Answers use the ids `page` returns: a choice id for a
single-choice question, a list of option ids for a multi-select one, and a
list of texts, one per blank, for a fill-in-the-blank one; questions left out
keep their saved answers. Equations read as LaTeX and images as
Expand Down
15 changes: 15 additions & 0 deletions docs/quiz-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,14 @@ here are placeholders (`ou=1001`, `qi=2002`, `ai=3003`, question ids 4xxx).
keeps it in the cursor. Whether a continued attempt's process page sets a
new one was not recorded, so the CLI takes a new one if set and otherwise
keeps the kept attempt's.
- Scheduled open: a summary read before the quiz opens still flags
`canTakeQuiz` and `startQuiz`, but renders `Start Quiz!` disabled and
calls `StartCountdown(<.NET ticks, UTC>);` on a line of its own (every
summary defines that function; only this one calls it). The script asks
the RPC `GetTimeUntilGivenDate` for the time left, counts down and then
enables the button, without reloading the page. The CLI reads the ticks
as data and, while Start is disabled, refuses with the open time and,
from the summary's `Date`, how long until then.
- Attempt frame: the process page opens
`attempt/quiz_attempt_iframe_auto.d2l?qi&ai&isprv=&dnb=0&drc=&pg=1&cfql=0&rldbsv=1&fromQB=0&cft=&d2l_body_type=1&ou`
(Referer the start's second frame). Its frames load with the frame URL as
Expand Down Expand Up @@ -337,6 +345,13 @@ here are placeholders (`ou=1001`, `qi=2002`, `ai=3003`, question ids 4xxx).
(`%7B%22param1%22%3A%222002%22,%22param2%22%3A%223003%22,...%7D`), while
`{ } " : [ ]` are percent-encoded. A form library's default encoding
writes `%2C`, so the CLI encodes these values itself.
- Hit codes: `d2l_hitCode` (`d2l_hitcode` in the RPC) is the session's
seed, the time in milliseconds modulo 10^8 and a counter modulo 10 kept
per window. Each page's `UI.Init` spends one as it loads, so a window's
first request ends in 2: the summary's start POST (3 after a countdown,
whose RPC spent 2), a page's first save, and the submit RPC, which the
attempt frame sends from its own window however many the page has spent.
The CLI counts the same way.
- Reloads: the CLI also reads pages a browser does not request again (the
summary, each command's first read of the cursor's page, a readback after
a save, `verify`'s reads). It sends those as a reload does, with
Expand Down
3 changes: 3 additions & 0 deletions lighthouse_cli/quiz_learner_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .quiz_attempt_page import PreviewPageError, PreviewRefusedError
from .quiz_learner_finish import LearnerNotSubmittedError, LearnerUnansweredError
from .quiz_learner_session import UNCERTAIN, LearnerWorkflow, LearnerWorkflowError, parse_answers
from .quiz_learner_transport import LearnerNotOpenError

_ID = click.IntRange(min=1, max=10**18 - 1)
_CONFIRMED = {"start", "answer", "next", "previous", "submit", "forget"}
Expand Down Expand Up @@ -80,6 +81,8 @@ def _execute(operation: str, course_id: int, quiz_id: int, json_output: bool,
error: dict[str, Any] = {"mode": "learner", "course_id": course_id, "quiz_id": quiz_id, "error": message}
if unanswered is not None:
error["unanswered"] = unanswered
if isinstance(exc, LearnerNotOpenError):
error.update(exc.public_data())
fail(message + listed, json_output, error)


Expand Down
6 changes: 4 additions & 2 deletions lighthouse_cli/quiz_learner_finish.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,11 @@ def submit_learner(
confirmation, unanswered = _confirmation(client, protection, view, finish)
if unanswered and not allow_unanswered:
raise LearnerUnansweredError(unanswered)
# isRldbUse is the browser's Boolean() of a non-empty string.
# isRldbUse is the browser's Boolean() of a non-empty string. The
# attempt frame sends the call, its window's first request.
frame = FormProtection(protection.csrf_token, protection.hit_code_seed)
body = rpc_body(view.quiz_id, view.attempt_id, rldb=bool(confirmation.get("HDN_isRldbUse", "")),
token=protection.csrf_token, hit_code=protection.next_hit_code())
token=protection.csrf_token, hit_code=frame.next_hit_code())
rpc = attempt_frame_path(view.course_id, view.quiz_id, view.attempt_id, view.frame_page, rpc=True)
response = client._request("POST", client.canonical_url(rpc), stream=True, data=body,
headers={"Content-Type": "application/x-www-form-urlencoded", "Origin": client.base_url,
Expand Down
60 changes: 54 additions & 6 deletions lighthouse_cli/quiz_learner_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@
from __future__ import annotations

import dataclasses
import math
import posixpath
import re
import time
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from typing import Any
from urllib.parse import parse_qs, unquote, urldefrag, urlparse
Expand Down Expand Up @@ -80,6 +82,28 @@ def __init__(self) -> None:
super().__init__("Page change could not be verified. Run attempt start to reopen the attempt where Brightspace has it.")


class LearnerNotOpenError(PreviewRefusedError):
"""A start before the quiz opens. ``opens_at`` is a Unix time; ``seconds_until_open`` is counted on the
server's clock, or ``None`` without its time. The message holds only these numbers."""

def __init__(self, opens_at: float, read_at: float | None) -> None:
self.opens_at = opens_at
self.seconds_until_open = None if read_at is None else max(0, math.ceil(opens_at - read_at))
local = datetime.fromtimestamp(opens_at).astimezone()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Accepted open time cannot display

_opens_at accepts 9999-12-31T23:59:59Z, but LearnerNotOpenError cannot format it in a local timezone east of UTC. That conversion requires year 10000 and raises ValueError. The command then returns a generic error without opens_at or seconds_until_open.

Fall back to UTC when the local date is out of range, and add a boundary test.

relative = ""
if self.seconds_until_open is not None and 0 < self.seconds_until_open < 3600:
seconds = self.seconds_until_open
count, unit = (seconds, "second") if seconds < 60 else (math.ceil(seconds / 60), "minute")
relative = f" (in {count} {unit}{'' if count == 1 else 's'})"
super().__init__(f"This quiz opens at {local:%H:%M} {local:%Z} on {local.day} {local:%B %Y}{relative}. "
"Nothing was started.")

def public_data(self) -> dict[str, Any]:
opens_at = datetime.fromtimestamp(self.opens_at, timezone.utc)
return {"opens_at": opens_at.isoformat(timespec="seconds").replace("+00:00", "Z"),
"seconds_until_open": self.seconds_until_open}


REFUSE_START_UNAVAILABLE = "This quiz cannot be started or continued now (closed, not yet open or out of attempts)."
REFUSE_NOTHING_IN_PROGRESS = "No attempt of this quiz is in progress."
REFUSE_START_PASSWORD = "This quiz needs a password, which the CLI does not support." # pragma: allowlist secret
Expand Down Expand Up @@ -108,9 +132,15 @@ def __init__(self) -> None:
# .NET ticks (100 ns since 0001-01-01 UTC) at the Unix epoch.
_UNIX_EPOCH_TICKS = 621_355_968_000_000_000
# How far ahead of the server's clock an attempt's start may be, and the
# earliest it may be (2000-01-01): an older one is no real start.
# earliest it may be (2000-01-01): an older one is no real start. A quiz's
# open time lies between that and the end of 2999, a time every zone's local
# clock can still show.
_START_SKEW_SECONDS = 300
_EARLIEST_START = 946_684_800
_LATEST_OPEN = 32_503_679_999
# Before a quiz opens its summary calls the countdown that enables Start,
# with the open time in .NET ticks, on its own line.
_COUNTDOWN = re.compile(r"^[ \t]*StartCountdown\(\s*([0-9]{1,19})\s*\)\s*;[ \t\r]*$", re.MULTILINE)
MAX_IMAGE_BYTES = 5 * 1024 * 1024
# Raster formats an agent can view, by their leading bytes.
_IMAGE_SIGNATURES = ((b"\x89PNG\r\n\x1a\n", "image/png"), (b"\xff\xd8\xff", "image/jpeg"),
Expand Down Expand Up @@ -164,6 +194,8 @@ class LearnerSummary:
``attempt_processing`` is the number of an attempt Brightspace is still
submitting after its time ran out. Until it has, the summary flags
Continue, yet that attempt cannot be continued and no new one started.
``opens_at`` is the Unix time a quiz not yet open opens, and ``_read_at``
the server's time when the page was read.
"""

course_id: int
Expand All @@ -172,20 +204,33 @@ class LearnerSummary:
can_continue: bool
attempt_in_progress: int | None
attempt_processing: int | None
opens_at: float | None
_flags: dict[str, bool] = field(repr=False, compare=False)
_hidden_fields: dict[str, str] = field(repr=False, compare=False)
_password_input: bool = field(repr=False, compare=False)
_button: str | None = field(repr=False, compare=False)
_protection: FormProtection | None = field(repr=False, compare=False)
_body: bytes = field(default=b"", repr=False, compare=False)
_read_at: float | None = field(default=None, repr=False, compare=False)

def public_data(self) -> dict[str, Any]:
return {"course_id": self.course_id, "quiz_id": self.quiz_id, "can_start": self.can_start,
"can_continue": self.can_continue, "attempt_in_progress": self.attempt_in_progress,
"attempt_processing": self.attempt_processing}


def parse_learner_summary(body: bytes, *, course_id: int, quiz_id: int) -> LearnerSummary:
def _opens_at(scripts: str) -> float | None:
"""The open time the summary counts down to, or ``None`` when it counts down to none."""
found = {int(ticks) for ticks in _COUNTDOWN.findall(scripts)}
if not found:
return None
opens_at = (found.pop() - _UNIX_EPOCH_TICKS) / 10**7
if found or not _EARLIEST_START <= opens_at <= _LATEST_OPEN:
raise PreviewPageError()
return opens_at


def parse_learner_summary(body: bytes, *, course_id: int, quiz_id: int, read_at: float | None = None) -> LearnerSummary:
"""Read the summary page's start state as data; its scripts never run."""
identity(course_id, quiz_id)
form, hidden = hidden_form(body)
Expand Down Expand Up @@ -219,16 +264,16 @@ def parse_learner_summary(body: bytes, *, course_id: int, quiz_id: int) -> Learn
can_start=takeable and flags["startQuiz"] and not flags["continueQuiz"],
can_continue=takeable and flags["continueQuiz"] and not flags["startQuiz"] and not processing,
attempt_in_progress=int(progress[0]) if progress else None,
attempt_processing=int(processing[0]) if processing else None,
attempt_processing=int(processing[0]) if processing else None, opens_at=_opens_at(scripts),
_flags=flags, _hidden_fields=hidden,
_password_input=bool(form.select('input[type="password"]') or soup.select('input[name="password"]')),
_button=labels[0] if labels else None, _protection=protection, _body=body,
_button=labels[0] if labels else None, _protection=protection, _body=body, _read_at=read_at,
)


def read_learner_summary(client: LighthouseClient, *, course_id: int, quiz_id: int) -> LearnerSummary:
body, _ = _get_page(client, summary_path(course_id, quiz_id), _document_headers(client, reload=True))
return parse_learner_summary(body, course_id=course_id, quiz_id=quiz_id)
body, headers = _get_page(client, summary_path(course_id, quiz_id), _document_headers(client, reload=True))
return parse_learner_summary(body, course_id=course_id, quiz_id=quiz_id, read_at=_server_time(headers))


@dataclass(frozen=True)
Expand Down Expand Up @@ -333,6 +378,9 @@ def _start_fields(summary: LearnerSummary, *, continue_only: bool) -> tuple[list
if continue_only and not resume:
raise PreviewRefusedError(REFUSE_NOTHING_IN_PROGRESS)
if summary._button != ("Continue Quiz..." if resume else "Start Quiz!"):
# Start stays disabled until the quiz opens.
if not resume and summary._button is None and summary.opens_at is not None:
raise LearnerNotOpenError(summary.opens_at, summary._read_at)
raise PreviewRefusedError(REFUSE_START_UNAVAILABLE)
fields = summary._hidden_fields
if fields.get("hps") or fields.get("LockDownBrowserUrl", "0") != "0":
Expand Down
6 changes: 4 additions & 2 deletions lighthouse_cli/quiz_preview_finish.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
rpc_script,
)
from .quiz_preview_transport import page_path, read_current_preview
from .request_protection import form_protection_from_homepage
from .request_protection import FormProtection, form_protection_from_homepage


class PreviewSubmitUnknownError(NetworkError):
Expand Down Expand Up @@ -120,12 +120,14 @@ def submit_preview(
rpc_url = client.canonical_url(parent_path + "file?" + urlencode({**context, "d2l_rh": "rpc", "d2l_rt": "call"}))
params = {"param1": str(quiz_id), "param2": str(attempt_id), "param3": True,
"param4": retain, "param5": integration_flag, "param6": False, "param7": ""}
# The attempt frame sends the call, its window's first request.
frame = FormProtection(protection.csrf_token, protection.hit_code_seed)
response = client._request("POST", rpc_url, stream=True,
data={"d2l_rf": "ProcessQuizSubmission",
# Brightspace rejects this RPC (error redirect) unless the
# params JSON is compact, as the browser sends it.
"params": json.dumps(params, separators=(",", ":")),
"d2l_referrer": protection.csrf_token, "d2l_hitcode": protection.next_hit_code(),
"d2l_referrer": protection.csrf_token, "d2l_hitcode": frame.next_hit_code(),
"d2l_action": "rpc"},
headers={"Referer": client.canonical_url(parent_path + "?" + urlencode(context))})
expected = f"parent.QuizDone({quiz_id},{attempt_id},'1','0','0','gotoSv','')"
Expand Down
4 changes: 3 additions & 1 deletion lighthouse_cli/request_protection.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ class FormProtection:
csrf_token: str = field(repr=False)
hit_code_seed: str = field(repr=False)
_last_tick: int = field(default=-1, repr=False)
_counter: int = field(default=0, repr=False)
# Each instance is one page's: its UI.Init spends the first hit code as it
# loads, so the page's first request sends the second, as the browser does.
_counter: int = field(default=1, repr=False)
_lock: Lock = field(default_factory=Lock, repr=False)

def next_hit_code(self) -> str:
Expand Down
Loading
Loading