| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798 |
- <?php
- /**
- * Server-side storage for the expected answer of the question a user is
- * currently looking at.
- *
- * Before: the generators put the correct answer in a hidden `checkerData`
- * field and the checker trusted whatever came back, so a student could edit
- * it. Now the payload stays on the server and the form only carries an opaque
- * single-use token (still posted as `checkerData`, so form markup is unchanged).
- *
- * Schema (add to timekeeper_sql.php in e107's format, or run once by hand with
- * the e107_ prefix on the table name):
- *
- * CREATE TABLE tkPendingAnswer (
- * username varchar(100) NOT NULL,
- * token char(32) NOT NULL,
- * payload text NOT NULL,
- * created int(10) unsigned NOT NULL,
- * PRIMARY KEY (username)
- * ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
- *
- * One pending row per user, matching the existing one-open-question model.
- */
- class TkAnswerStore
- {
- const TABLE = 'tkPendingAnswer';
- const TTL = 7200; // seconds a question stays answerable
- /**
- * Store the expected-answer payload for $user and return the token to
- * embed in the form's hidden `checkerData` field.
- *
- * @param string $user
- * @param array $payload e.g. ['answer1' => 42, 'question' => '6 x 7'] or ['word' => 'because']
- * @return string
- */
- public static function issue($user, array $payload)
- {
- $db = e107::getDb();
- $u = $db->escape($user);
- // Replace this user's pending row and sweep expired ones.
- $db->delete(self::TABLE, "username = '{$u}' OR created < " . (time() - self::TTL));
- $token = bin2hex(random_bytes(16));
- $db->insert(self::TABLE, [
- 'username' => $user,
- 'token' => $token,
- 'payload' => json_encode($payload),
- 'created' => time(),
- ]);
- return $token;
- }
- /**
- * Validate the posted token and return the payload, consuming it so the
- * same question cannot be scored twice. Returns null if the token is
- * missing, wrong (e.g. stale browser tab), expired, or already used.
- *
- * @param string $user
- * @param string|null $token value of POST `checkerData`
- * @return array|null
- */
- public static function consume($user, $token)
- {
- if (!is_string($token) || $token === '') {
- error_log("TK consume: no token posted (user=$user)");
- return null;
- }
- $db = e107::getDb();
- $u = $db->escape($user);
- $row = $db->retrieve(self::TABLE, 'token, payload, created', "username = '{$u}'");
- if (!is_array($row)) {
- error_log("TK consume: no pending row for user=$user");
- return null;
- }
- if (!hash_equals((string) $row['token'], $token)) {
- error_log("TK consume: token mismatch user=$user posted=" . substr($token, 0, 40));
- return null;
- }
- $deleted = $db->delete(self::TABLE, "username = '{$u}' AND token = '" . $db->escape($row['token']) . "'");
- error_log("TK consume: delete returned " . var_export($deleted, true));
- // Temporarily ignore the delete result while diagnosing:
- // if (!$deleted) { return null; }
- if (time() - (int) $row['created'] > self::TTL) {
- error_log("TK consume: expired");
- return null;
- }
- $payload = json_decode($row['payload'], true);
- return is_array($payload) ? $payload : null;
- }
- }
|