TkAnswerStore.php 2.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
  1. <?php
  2. /**
  3. * Server-side storage for the expected answer of the question a user is
  4. * currently looking at.
  5. *
  6. * The generators store the expected-answer payload here and put only an
  7. * opaque single-use token in the form's hidden `checkerData` field, so a
  8. * student cannot edit the answer client-side.
  9. *
  10. * Table: tkPendingAnswer (username PK, token, payload, created) - see
  11. * timekeeper_sql.php. One pending row per user.
  12. */
  13. class TkAnswerStore
  14. {
  15. const TABLE = 'tkPendingAnswer';
  16. const TTL = 7200; // seconds a question stays answerable
  17. /**
  18. * Store the expected-answer payload for $user and return the token to
  19. * embed in the form's hidden `checkerData` field.
  20. *
  21. * @param string $user
  22. * @param array $payload e.g. ['answer1' => 42, 'question' => [...]] or ['word' => 'because']
  23. * @return string
  24. */
  25. public static function issue($user, array $payload)
  26. {
  27. $db = e107::getDb();
  28. $u = $db->escape($user);
  29. // Replace this user's pending row and sweep expired ones.
  30. $db->delete(self::TABLE, "username = '{$u}' OR created < " . (time() - self::TTL));
  31. $token = bin2hex(random_bytes(16));
  32. $db->insert(self::TABLE, [
  33. 'username' => $user,
  34. 'token' => $token,
  35. 'payload' => json_encode($payload),
  36. 'created' => time(),
  37. ]);
  38. return $token;
  39. }
  40. /**
  41. * Validate the posted token and return the payload, consuming it so the
  42. * same question cannot be scored twice. Returns null if the token is
  43. * missing, wrong (e.g. stale browser tab), expired, or already used.
  44. *
  45. * @param string $user
  46. * @param string|null $token value of POST `checkerData`
  47. * @return array|null
  48. */
  49. public static function consume($user, $token)
  50. {
  51. if (!is_string($token) || $token === '') {
  52. return null;
  53. }
  54. $db = e107::getDb();
  55. $u = $db->escape($user);
  56. $row = $db->retrieve(self::TABLE, 'token, payload, created', "username = '{$u}'");
  57. if (!is_array($row) || !hash_equals((string) $row['token'], $token)) {
  58. return null; // wrong token: leave the real pending question intact
  59. }
  60. // Single use: delete() returns the affected-row count, so of two
  61. // simultaneous submits only the first gets 1 here.
  62. if (!$db->delete(self::TABLE, "username = '{$u}' AND token = '" . $db->escape($row['token']) . "'")) {
  63. return null;
  64. }
  65. if (time() - (int) $row['created'] > self::TTL) {
  66. return null;
  67. }
  68. $payload = json_decode($row['payload'], true);
  69. return is_array($payload) ? $payload : null;
  70. }
  71. }