PreHeaderFunctions.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. <?php
  2. /**
  3. * Report exports for TimeKeeper:
  4. * - ExcelFunctions::GeneratePdf() question report as HTML or PDF
  5. * - ExcelFunctions::GenerateExcel() worksheet of random maths questions
  6. * - Summary::GenerateSummary() kiosk-style score summary
  7. *
  8. * Changes from the current repo version:
  9. * - ?user= is validated once, up front, for every entry point (GenerateSummary
  10. * had no validation at all, and GeneratePdf only validated inside the pdf
  11. * branch, after the SQL had already run with the raw value)
  12. * - all SQL escapes the user and uses MPREFIX instead of `e107.e107_`
  13. * - everything taken from the database or the URL is HTML-escaped (answers
  14. * are typed by the students, so they are untrusted)
  15. * - the correct/incorrect icons are text symbols: the <img> paths were
  16. * relative, so they broke once the PDF source moved to the temp directory
  17. * - PDF: wkhtmltopdf runs with local file access disabled, the PDF is only
  18. * sent if it was actually produced, and the temp files are always removed
  19. * - removed the debug "Memory use / Generation time" line and the early
  20. * $sql->close() (it closed the shared connection before e107 finished)
  21. * - Excel: the question loop started at row 0 (invalid, and the formulas
  22. * pointed at row 0), multiplication passed an array to rand() (fatal on
  23. * PHP 8), buildQuestion() looped forever if no subject was ticked, and the
  24. * question count was unbounded
  25. * - whereDate()/range handling lives in one trait instead of two copies
  26. * - removed the dead, broken coverPage() in ExcelFunctions (COUNT(_id))
  27. */
  28. trait TkReportRequest {
  29. /** Validated ?user= value, or null when missing or not a plain username. */
  30. private function requestUser() {
  31. $user = filter_input(INPUT_GET, 'user');
  32. if (is_string($user) && preg_match('/^[A-Za-z0-9_.-]{1,50}$/', $user)) {
  33. return $user;
  34. }
  35. return null;
  36. }
  37. private function rangeWindows() {
  38. $end = mktime(23, 59, 59, date('n'), date('j'));
  39. return [
  40. 'today' => ['start' => strtotime('- 24 hours', $end), 'end' => $end],
  41. 'yesterday' => ['start' => strtotime('- 48 hours', $end), 'end' => strtotime('- 24 hours', $end)],
  42. 'pastWeek' => ['start' => strtotime('- 7 days', $end), 'end' => $end],
  43. 'pastMonth' => ['start' => strtotime('- 1 month', $end), 'end' => $end],
  44. 'pastQuarter' => ['start' => strtotime('- 3 month', $end), 'end' => $end],
  45. 'pastYear' => ['start' => strtotime('- 1 year', $end), 'end' => $end],
  46. ];
  47. }
  48. /** The requested ?range=, restricted to the known names ('today' otherwise). */
  49. private function currentRange() {
  50. $range = filter_input(INPUT_GET, 'range');
  51. $windows = $this->rangeWindows();
  52. return (is_string($range) && isset($windows[$range])) ? $range : 'today';
  53. }
  54. private function whereDate() {
  55. $windows = $this->rangeWindows();
  56. $window = $windows[$this->currentRange()];
  57. return 'AND answereddatestamp>=' . (int) $window['start'] . ' AND answereddatestamp<=' . (int) $window['end'];
  58. }
  59. private function esc($value) {
  60. return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
  61. }
  62. }
  63. class ExcelFunctions {
  64. use TkReportRequest;
  65. public function GeneratePdf() {
  66. $user = $this->requestUser();
  67. if ($user === null) {
  68. return;
  69. }
  70. $output = filter_input(INPUT_GET, 'output');
  71. if ($output !== 'html' && $output !== 'pdf') {
  72. return;
  73. }
  74. $sql = e107::getDb();
  75. $text = '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><h1>TimeKeeper Report</h1>'
  76. . $this->questionsTable($sql, $user, $this->whereDate())
  77. . '</body></html>';
  78. if ($output === 'html') {
  79. echo $text;
  80. exit;
  81. }
  82. $base = tempnam(sys_get_temp_dir(), 'tk');
  83. if ($base === false) {
  84. return;
  85. }
  86. $html = $base . '.html';
  87. $pdf = $base . '.pdf';
  88. file_put_contents($html, $text);
  89. exec('wkhtmltopdf --quiet --disable-local-file-access ' . escapeshellarg($html) . ' ' . escapeshellarg($pdf) . ' 2>&1');
  90. if (is_file($pdf) && filesize($pdf) > 0) {
  91. header('Content-Type: application/pdf');
  92. header('Content-Length: ' . filesize($pdf));
  93. header('Content-Disposition: inline; filename="timekeeper-report.pdf"');
  94. readfile($pdf);
  95. } else {
  96. header('HTTP/1.1 500 Internal Server Error');
  97. echo 'The PDF could not be generated.';
  98. }
  99. @unlink($html);
  100. @unlink($pdf);
  101. @unlink($base);
  102. exit;
  103. }
  104. private function questionsTable($sql, $user, $whereDate) {
  105. $sql->gen("SELECT * FROM `" . MPREFIX . "tkReport` WHERE username='" . $sql->escape($user) . "' $whereDate ORDER BY subject, answereddatestamp");
  106. $tableHead = '<table border="1" cellspacing="3" cellpadding="2"><tr>'
  107. . '<td width="1295">Question</td>'
  108. . '<td width="75">User Answer</td>'
  109. . '<td width="75">Correct Answer</td>'
  110. . '<td width="25"></td>'
  111. . '</tr>';
  112. $head = null;
  113. $text = '';
  114. while ($row = $sql->fetch()) {
  115. if ($row['subject'] !== $head) {
  116. if ($head !== null) {
  117. $text .= '</table>';
  118. }
  119. $head = $row['subject'];
  120. $text .= '<h3>' . $this->esc(ucfirst($head)) . '</h3>' . $tableHead;
  121. }
  122. $text .= '<tr><td>' . $this->esc($row['question']) . '</td><td>' . $this->esc($row['userAnswer']) . '</td><td>' . $this->esc($row['answer']) . '</td><td>' . $this->correctOrNot($row['correct']) . '</td></tr>';
  123. }
  124. if ($head === null) {
  125. return '<p>No questions were answered in this period.</p>';
  126. }
  127. return $text . '</table>';
  128. }
  129. private function correctOrNot($state) {
  130. if ($state) {
  131. return '<span style="color:green">&#10004;</span>';
  132. }
  133. return '<span style="color:red">&#10008;</span>';
  134. }
  135. public function GenerateExcel() {
  136. $lib = __DIR__ . '/lib/PHPExcel.phar';
  137. if (!is_file($lib)) {
  138. e107::getMessage()->addError('The Excel library (lib/PHPExcel.phar) is not installed.');
  139. return;
  140. }
  141. require_once('phar://' . $lib . '/PHPExcel.php');
  142. $objPHPExcel = new PHPExcel();
  143. $objPHPExcel->getProperties()->setCreator("Gregory J Huxley")->setLastModifiedBy("TimeKeeper");
  144. $this->buildSheet($objPHPExcel);
  145. $this->setHeader();
  146. $objWriter = PHPExcel_IOFactory::createWriter($objPHPExcel, 'Excel5');
  147. $objWriter->setPreCalculateFormulas(true);
  148. $objWriter->save('php://output');
  149. exit;
  150. }
  151. private function buildSheet($objPHPExcel) {
  152. $numberOfQuestions = (int) filter_input(INPUT_POST, 'numberOfQuestions');
  153. if ($numberOfQuestions < 1) {
  154. $numberOfQuestions = 5;
  155. }
  156. $numberOfQuestions = min($numberOfQuestions, 500);
  157. $sheet = $objPHPExcel->setActiveSheetIndex(0);
  158. $sheet->setTitle("Questions");
  159. // Rows are 1-based, and the answer-check formulas refer to row $i.
  160. for ($i = 1; $i <= $numberOfQuestions; $i++) {
  161. $counter = 0;
  162. foreach ($this->buildQuestion($i) as $value) {
  163. $sheet->setCellValueByColumnAndRow($counter, $i, $value);
  164. $counter++;
  165. }
  166. }
  167. }
  168. private function buildQuestion($i) {//http://localhost/e107_plugins/timekeeper/timekeeper.php?numberOfQuestions=20&addition=0&addition__switch=0&subtraction=0&multiplication=0&division=0&GetExcel=Generate+Sheet
  169. $subjects = ['addition', 'subtraction', 'multiplication', 'division'];
  170. $enabled = [];
  171. foreach ($subjects as $subject) {
  172. if (filter_has_var(INPUT_POST, $subject . '__switch')) {
  173. $enabled[] = $subject;
  174. }
  175. }
  176. if (empty($enabled)) {
  177. $enabled = $subjects; // nothing ticked: use all of them rather than looping forever
  178. }
  179. switch ($enabled[array_rand($enabled)]) {
  180. case 'addition':
  181. return $this->addition($i);
  182. case 'subtraction':
  183. return $this->subtraction($i);
  184. case 'multiplication':
  185. return $this->multiplication($i);
  186. default:
  187. return $this->division($i);
  188. }
  189. }
  190. private function addition($i) {
  191. $question = [rand(1, 1000)];
  192. $extra = rand(1, 3);
  193. for ($int = 1; $int <= $extra; $int++) {
  194. array_push($question, '+');
  195. array_push($question, rand(1, 1000));
  196. }
  197. array_push($question, '=');
  198. array_push($question, $this->checkFormula($i, $question));
  199. return $question;
  200. }
  201. private function subtraction($i) {
  202. $question = [rand(100, 1000)];
  203. $extra = rand(1, 2);
  204. for ($int = 1; $int <= $extra; $int++) {
  205. array_push($question, '-');
  206. array_push($question, rand(1, (int) ($question[0] / 2)));
  207. }
  208. array_push($question, '=');
  209. array_push($question, $this->checkFormula($i, $question));
  210. return $question;
  211. }
  212. private function multiplication($i) {
  213. $startNumber = rand(1, 99);
  214. $question = [$startNumber];
  215. array_push($question, '*');
  216. array_push($question, rand(1, $startNumber));
  217. array_push($question, '=');
  218. array_push($question, $this->checkFormula($i, $question));
  219. return $question;
  220. }
  221. private function division($i) {
  222. $startNumber = rand(1, 50);
  223. $endNumber = rand(1, 5);
  224. $question = [$startNumber * $endNumber];
  225. array_push($question, '/');
  226. array_push($question, $endNumber);
  227. array_push($question, '=');
  228. array_push($question, $this->checkFormula($i, $question));
  229. return $question;
  230. }
  231. private function checkFormula($i, &$question) {
  232. $letters = ['A', 'C', 'E', 'G', 'I'];
  233. for ($int = count($question); $int <= 8; $int++) {
  234. array_push($question, '');
  235. }
  236. $formula = '(';
  237. $counter = 0;
  238. foreach ($question as &$value) {
  239. if ($value == '+' || $value == '-' || $value == '*' || $value == '/') {
  240. $formula .= $value;
  241. } elseif ($value == '=') {
  242. $formula .= ')=' . $letters[$counter] . $i;
  243. break;
  244. } else {
  245. $formula .= $letters[$counter] . $i;
  246. $counter++;
  247. }
  248. }
  249. unset($value);
  250. return '=IF(' . substr(strrchr($formula, "="), 1) . '="","",IF(' . $formula . ',"Correct","Wrong"))'; // =IF(L1="","",IF((A1*C1*E1*H1*J1)=L1,"Correct","Wrong"))
  251. }
  252. private function setHeader() {
  253. // Redirect output to a client's web browser (Excel5)
  254. header('Content-Type: application/vnd.ms-excel');
  255. header('Content-Disposition: attachment;filename="Questions.xls"');
  256. header('Cache-Control: max-age=0');
  257. // If you're serving to IE 9, then the following may be needed
  258. header('Cache-Control: max-age=1');
  259. // If you're serving to IE over SSL, then the following may be needed
  260. header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); // Date in the past
  261. header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); // always modified
  262. header('Cache-Control: cache, must-revalidate'); // HTTP/1.1
  263. header('Pragma: public'); // HTTP/1.0
  264. }
  265. }
  266. class Summary {
  267. use TkReportRequest;
  268. public function GenerateSummary() {
  269. $user = $this->requestUser();
  270. if ($user === null) {
  271. return;
  272. }
  273. $sql = e107::getDb();
  274. echo '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
  275. . '<body style="background-color:#31363b;"><div style="font-size:250%;color:#fcfcfc;">'
  276. . $this->coverPage($sql, $user, $this->whereDate())
  277. . '</div></body></html>';
  278. exit;
  279. }
  280. private function coverPage($sql, $user, $whereDate) {
  281. $sql->gen("SELECT COUNT(id) AS cntr, CAST((SUM(correct)/COUNT(id)*100) AS DECIMAL(5,2)) AS percent
  282. FROM `" . MPREFIX . "tkReport` WHERE username = '" . $sql->escape($user) . "'
  283. $whereDate");
  284. $row = $sql->fetch();
  285. $count = is_array($row) ? (int) $row['cntr'] : 0;
  286. $percent = (is_array($row) && $row['percent'] !== null) ? $row['percent'] : 0;
  287. $range = $this->esc($this->currentRange());
  288. if ($range === 'today') {
  289. return 'Questions ' . $range . '<br/><br/>' . $count . '<br/>' . $percent . '% correct.';
  290. }
  291. return 'Questions ' . $range . ' ' . $count . '<br/><br/>' . $percent . '% correct.';
  292. }
  293. }