timer.php 11 KB

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