TkAnswerStore.php 3.3 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. <?php
  2. /**
  3. * Server-side storage for the expected answer of the question a user is
  4. * currently looking at.
  5. *
  6. * Before: the generators put the correct answer in a hidden `checkerData`
  7. * field and the checker trusted whatever came back, so a student could edit
  8. * it. Now the payload stays on the server and the form only carries an opaque
  9. * single-use token (still posted as `checkerData`, so form markup is unchanged).
  10. *
  11. * Schema (add to timekeeper_sql.php in e107's format, or run once by hand with
  12. * the e107_ prefix on the table name):
  13. *
  14. * CREATE TABLE tkPendingAnswer (
  15. * username varchar(100) NOT NULL,
  16. * token char(32) NOT NULL,
  17. * payload text NOT NULL,
  18. * created int(10) unsigned NOT NULL,
  19. * PRIMARY KEY (username)
  20. * ) ENGINE=InnoDB DEFAULT CHARSET=utf8;
  21. *
  22. * One pending row per user, matching the existing one-open-question model.
  23. */
  24. class TkAnswerStore
  25. {
  26. const TABLE = 'tkPendingAnswer';
  27. const TTL = 7200; // seconds a question stays answerable
  28. /**
  29. * Store the expected-answer payload for $user and return the token to
  30. * embed in the form's hidden `checkerData` field.
  31. *
  32. * @param string $user
  33. * @param array $payload e.g. ['answer1' => 42, 'question' => '6 x 7'] or ['word' => 'because']
  34. * @return string
  35. */
  36. public static function issue($user, array $payload)
  37. {
  38. $db = e107::getDb();
  39. $u = $db->escape($user);
  40. // Replace this user's pending row and sweep expired ones.
  41. $db->delete(self::TABLE, "username = '{$u}' OR created < " . (time() - self::TTL));
  42. $token = bin2hex(random_bytes(16));
  43. $db->insert(self::TABLE, [
  44. 'username' => $user,
  45. 'token' => $token,
  46. 'payload' => json_encode($payload),
  47. 'created' => time(),
  48. ]);
  49. return $token;
  50. }
  51. /**
  52. * Validate the posted token and return the payload, consuming it so the
  53. * same question cannot be scored twice. Returns null if the token is
  54. * missing, wrong (e.g. stale browser tab), expired, or already used.
  55. *
  56. * @param string $user
  57. * @param string|null $token value of POST `checkerData`
  58. * @return array|null
  59. */
  60. public static function consume($user, $token)
  61. {
  62. if (!is_string($token) || $token === '') {
  63. error_log("TK consume: no token posted (user=$user)");
  64. return null;
  65. }
  66. $db = e107::getDb();
  67. $u = $db->escape($user);
  68. $row = $db->retrieve(self::TABLE, 'token, payload, created', "username = '{$u}'");
  69. if (!is_array($row)) {
  70. error_log("TK consume: no pending row for user=$user");
  71. return null;
  72. }
  73. if (!hash_equals((string) $row['token'], $token)) {
  74. error_log("TK consume: token mismatch user=$user posted=" . substr($token, 0, 40));
  75. return null;
  76. }
  77. $deleted = $db->delete(self::TABLE, "username = '{$u}' AND token = '" . $db->escape($row['token']) . "'");
  78. error_log("TK consume: delete returned " . var_export($deleted, true));
  79. // Temporarily ignore the delete result while diagnosing:
  80. // if (!$deleted) { return null; }
  81. if (time() - (int) $row['created'] > self::TTL) {
  82. error_log("TK consume: expired");
  83. return null;
  84. }
  85. $payload = json_decode($row['payload'], true);
  86. return is_array($payload) ? $payload : null;
  87. }
  88. }