timer.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. <?php
  2. /**
  3. * Timekeeper timer endpoint: timekeeperClientServices/timer.php?user=<name>&game=<process>
  4. *
  5. * Called once a minute by timekeeperLocal.php on the student's machine. The
  6. * reply is either the number of play minutes remaining (a number) or a
  7. * message explaining why play isn't allowed (any non-number: the client
  8. * shows it and closes the game).
  9. *
  10. * Changes from the current repo version:
  11. * - ?user= and ?game= were concatenated straight into SQL with no login
  12. * required (SQL injection), and ?user= was also used to build a file name
  13. * for debug output (arbitrary file write). Both are now validated against
  14. * a strict pattern and escaped, and the debug file is gone (that's what
  15. * the *debug.dat files in the repo were)
  16. * - unknown users are only auto-created if they have an e107 account (any
  17. * visitor could create rows by requesting ?user=anything)
  18. * - removed the leftover "reply 31 until 2026-05-13" override
  19. * - counters are updated with atomic UPDATE ... SET x = x + 1 instead of
  20. * writing back a value read earlier in the request
  21. * - when the free minutes run out the game was killed even if the student
  22. * had earned minutes: remaining free time of exactly 0 replied "0".
  23. * Earned minutes now take over, and the reply is 0 (never negative)
  24. * once there are none, so the client keeps closing the game
  25. * - new ?peek=1: reply with the minutes remaining WITHOUT using one up.
  26. * The client calls it when it first sees a game so the first minute is no
  27. * longer charged twice
  28. * - plain-text response header
  29. */
  30. if (!defined('e107_INIT')) {
  31. require_once('../../../class2.php');
  32. }
  33. if (!e107::isInstalled('timekeeper')) {
  34. e107::redirect();
  35. exit;
  36. }
  37. /**
  38. * The Timekeeper's timer connection class.
  39. *
  40. * This class checks if a user has the right to play games and if not states why.
  41. * This is an adapter to the local Timekeeper application monitor.
  42. *
  43. * @author gregory
  44. */
  45. class timer {
  46. private $user = null;
  47. private $game = '';
  48. private $invalid = false;
  49. private $peek = false;
  50. private $debug = false;
  51. private $sql;
  52. private $settings;
  53. function __construct() {
  54. $user = filter_input(INPUT_GET, 'user');
  55. if (is_string($user) && preg_match('/^[A-Za-z0-9_.-]{1,50}$/', $user)) {
  56. $this->user = $user;
  57. } else {
  58. $this->invalid = true;
  59. }
  60. $game = filter_input(INPUT_GET, 'game');
  61. if (is_string($game) && $game !== '') {
  62. if (preg_match('/^[\w.+: ()\-]{1,100}$/', $game)) {
  63. $this->game = $game;
  64. } else {
  65. $this->invalid = true;
  66. }
  67. }
  68. $this->peek = filter_input(INPUT_GET, 'peek') ? true : false;
  69. $this->debug = filter_input(INPUT_GET, 'dbg') ? true : false;
  70. $this->sql = e107::getDb();
  71. date_default_timezone_set('Australia/Perth');
  72. }
  73. private function q($value) {
  74. return $this->sql->escape($value);
  75. }
  76. private function debug($text) {
  77. if ($this->debug) {
  78. echo "Entered " . $text . "<br/>";
  79. }
  80. }
  81. /**
  82. * Main entry point.
  83. */
  84. public function main() {
  85. if (!headers_sent()) {
  86. header('Content-Type: text/plain; charset=utf-8');
  87. }
  88. if ($this->invalid || $this->user === null) {
  89. echo 'Invalid request.';
  90. return;
  91. }
  92. $this->debug(__FUNCTION__);
  93. // load the settings and init values from the database
  94. $this->getSettings();
  95. // Check if user is allowed to play games, if not reply and exit now
  96. if (!$this->isTimekeeperOperable()) {
  97. return;
  98. }
  99. if (!$this->checkPlayTime()) {
  100. return;
  101. }
  102. // If a game is being played then update the statistics database
  103. if ($this->charging()) {
  104. $this->playTimeStatistics();
  105. }
  106. }
  107. /**
  108. * Is this request supposed to use up a minute of play time?
  109. */
  110. private function charging() {
  111. return $this->game !== '' && !$this->peek;
  112. }
  113. private function getSettings() {
  114. $this->debug(__FUNCTION__);
  115. $this->settings = $this->sql->retrieve('timekeeper', '*', 'username="' . $this->q($this->user) . '"');
  116. }
  117. /**
  118. * Should the timekeeper be operable for this user right now?
  119. *
  120. * @return boolean
  121. */
  122. private function isTimekeeperOperable() {
  123. $this->debug(__FUNCTION__);
  124. if (is_array($this->settings) && isset($this->settings['enabled'])) {
  125. // timekeeper switched off for this user: no limit (reply 30 as before)
  126. if (!$this->settings['enabled']) {
  127. echo 30;
  128. return false;
  129. }
  130. } else {
  131. // Only people with an e107 account get a settings row.
  132. if ($this->sql->count('user', '(*)', 'user_loginname="' . $this->q($this->user) . '"') < 1) {
  133. echo 'Unknown user.';
  134. return false;
  135. }
  136. $query = ['username' => $this->user, 'enabled' => 1, 'freeMins' => 10, 'enableTime' => 10.00, 'disableTime' => 22.00, 'minQuestionCount' => 100, 'usedMinutes' => 0, 'lastDate' => 0, 'maxDailyPlayTime' => 1440];
  137. $this->sql->insert('timekeeper', $query);
  138. echo $this->user . ' added to timekeepers database.';
  139. // exit here as the class variable would not be setup for the user.
  140. exit;
  141. }
  142. // Check if user is within allowed play times
  143. if (!$this->playTimeWindow()) {
  144. echo 'You are not within allowed play times.';
  145. return false;
  146. }
  147. // Check has the user answered enough questions today.
  148. if (!$this->minimumQuestionsMet()) {
  149. if ($this->settings['freeMins'] >= 1) {
  150. return true;
  151. }
  152. echo 'You have not answered enough questions to play.';
  153. return false;
  154. }
  155. return true;
  156. }
  157. /**
  158. * Is the time of day within the play window?
  159. *
  160. * @return boolean
  161. */
  162. private function playTimeWindow() {
  163. $this->debug(__FUNCTION__);
  164. if (isset($this->settings['enableTime']) && isset($this->settings['disableTime'])) {
  165. // Disable time is less than enable time consider it the next day
  166. if ((double) date('H.i') <= (double) $this->settings['disableTime'] && (double) $this->settings['disableTime'] <= (double) $this->settings['enableTime']) {
  167. return true;
  168. }
  169. // Check if the enable time has been met, return false if not
  170. if ((double) date('H.i') <= (double) $this->settings['enableTime']) {
  171. return false;
  172. }
  173. // Check if the disable time has been exceeded (not when disable < enable)
  174. if ((double) date('H.i') >= (double) $this->settings['disableTime'] && !((double) $this->settings['disableTime'] <= (double) $this->settings['enableTime'])) {
  175. return false;
  176. }
  177. }
  178. return true;
  179. }
  180. /**
  181. * Has the user answered at least minQuestionCount questions today?
  182. *
  183. * @return boolean
  184. */
  185. private function minimumQuestionsMet() {
  186. $this->debug(__FUNCTION__);
  187. if (isset($this->settings['minQuestionCount'])) {
  188. $answered = (int) $this->sql->retrieve('tkReport', 'COUNT(id)', 'username="' . $this->q($this->user) . '" '
  189. . 'AND answereddatestamp >= ' . (int) mktime(0, 0, 0, date('n'), date('j')) . ' '
  190. . 'AND answereddatestamp <= ' . (int) mktime(23, 59, 59, date('n'), date('j')));
  191. if ($answered < (int) $this->settings['minQuestionCount']) {
  192. return false;
  193. }
  194. }
  195. return true;
  196. }
  197. /**
  198. * Work out the minutes remaining, and (unless peeking) use one up.
  199. *
  200. * Free minutes (freeMins minus today's usedMinutes) are used first; once
  201. * they are gone the minutes earned from answering questions are used.
  202. *
  203. * @return boolean False when the user may not play now (reply already sent).
  204. */
  205. private function checkPlayTime() {
  206. $this->debug(__FUNCTION__);
  207. $s = $this->settings;
  208. if (!isset($s['freeMins']) || !isset($s['usedMinutes']) || !isset($s['lastDate']) || !isset($s['maxDailyPlayTime'])) {
  209. return true;
  210. }
  211. // New day: today's counter starts again from zero.
  212. $isToday = ((int) $s['lastDate'] > 0) && date('Ymd', (int) $s['lastDate']) === date('Ymd');
  213. $used = $isToday ? (int) $s['usedMinutes'] : 0;
  214. if ($used >= (int) $s['maxDailyPlayTime']) {
  215. echo 'Max play time exceeded.';
  216. return false;
  217. }
  218. $free = (int) $s['freeMins'] - $used;
  219. if ($free > 0) {
  220. // Free play time left: use it before earned minutes.
  221. $this->useMinute($isToday, false);
  222. echo $free;
  223. return true;
  224. }
  225. // Free time is gone, so the questions-answered rule applies.
  226. if (!$this->minimumQuestionsMet()) {
  227. echo 'You have not answered enough questions to play.';
  228. return false;
  229. }
  230. $earned = (int) $this->sql->retrieve('timekeeperScore', 'timekeeper_minutes', 'timekeeper_username="' . $this->q($this->user) . '"');
  231. if ($earned <= 0) {
  232. echo 0;
  233. return false;
  234. }
  235. $this->useMinute($isToday, true);
  236. echo $earned;
  237. return true;
  238. }
  239. /**
  240. * Use up one minute of play time (no-op when peeking or no game given).
  241. *
  242. * @param boolean $isToday Whether the stored counter belongs to today.
  243. * @param boolean $fromScore Also take the minute off the earned minutes.
  244. */
  245. private function useMinute($isToday, $fromScore) {
  246. if (!$this->charging()) {
  247. return;
  248. }
  249. $u = $this->q($this->user);
  250. if ($isToday) {
  251. $this->sql->gen("UPDATE `" . MPREFIX . "timekeeper` SET `usedMinutes` = `usedMinutes` + 1 WHERE `username` = '{$u}'");
  252. } else {
  253. $this->sql->gen("UPDATE `" . MPREFIX . "timekeeper` SET `lastDate` = " . (int) time() . ", `usedMinutes` = 1 WHERE `username` = '{$u}'");
  254. }
  255. if ($fromScore) {
  256. $this->sql->gen("UPDATE `" . MPREFIX . "timekeeperScore` SET `timekeeper_minutes` = `timekeeper_minutes` - 1 WHERE `timekeeper_username` = '{$u}' AND `timekeeper_minutes` > 0");
  257. }
  258. }
  259. /**
  260. * Update the statistics for games played and for how long.
  261. */
  262. private function playTimeStatistics() {
  263. $this->debug(__FUNCTION__);
  264. $where = 'userName="' . $this->q($this->user) . '" AND gameName="' . $this->q($this->game) . '" AND playDate="' . (int) mktime(12, 0, 0, date('n'), date('j')) . '"';
  265. $playMinutes = (int) $this->sql->retrieve('timekeeperStatistics', 'minutesPlayed', $where);
  266. if ($playMinutes === 0) {
  267. $this->sql->insert('timekeeperStatistics', ['uniqueId' => 0, 'minutesPlayed' => 1, 'userName' => $this->user, 'gameName' => $this->game, 'playDate' => mktime(12, 0, 0, date('n'), date('j'))]);
  268. } else {
  269. $this->sql->update('timekeeperStatistics', ['minutesPlayed' => ($playMinutes + 1), 'WHERE' => $where]);
  270. }
  271. }
  272. }
  273. //run the timer class as standalone
  274. $timerClass = new timer();
  275. $timerClass->main();