AuditController.php 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. <?php
  2. /*
  3. * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
  4. * SPDX-License-Identifier: Apache-2.0
  5. *
  6. * Licensed under the Apache License, Version 2.0 (the "License");
  7. * you may not use this file except in compliance with the License.
  8. * See the LICENSE file in the project root for the full license text.
  9. */
  10. declare(strict_types=1);
  11. namespace App\Controllers;
  12. use App\Auth\SessionGuard;
  13. use App\Http\Request;
  14. use App\Http\Response;
  15. use App\Http\View;
  16. use App\Repositories\AuditRepository;
  17. use App\Repositories\UserRepository;
  18. use DateTimeImmutable;
  19. /**
  20. * /audit — admin-only read-only viewer.
  21. *
  22. * Never writes to the audit log (that goes through AuditLogger), and there
  23. * is NO admin action here that truncates or deletes rows. The spec forbids
  24. * that.
  25. */
  26. final class AuditController
  27. {
  28. private const PAGE_SIZE = 50;
  29. public function __construct(
  30. private readonly UserRepository $users,
  31. private readonly AuditRepository $audit,
  32. private readonly View $view,
  33. ) {
  34. }
  35. public function index(Request $req): Response
  36. {
  37. $actor = SessionGuard::requireAdmin($this->users);
  38. if ($actor instanceof Response) {
  39. return $actor;
  40. }
  41. $rawFrom = $req->queryString('from_date');
  42. $rawTo = $req->queryString('to_date');
  43. $dates = self::validateDateFilters($rawFrom, $rawTo);
  44. // Form inputs echo what the user typed (so they can fix a typo);
  45. // the repo only sees the values that parsed cleanly as Y-m-d.
  46. $filters = [
  47. 'user_email' => $req->queryString('user_email'),
  48. 'action' => $req->queryString('action'),
  49. 'entity_type' => $req->queryString('entity_type'),
  50. 'entity_id' => $req->queryString('entity_id'),
  51. 'from_date' => $rawFrom,
  52. 'to_date' => $rawTo,
  53. ];
  54. $effectiveFilters = $filters;
  55. $effectiveFilters['from_date'] = $dates['from'];
  56. $effectiveFilters['to_date'] = $dates['to'];
  57. $page = (int) ($req->queryString('page') ?: '1');
  58. $result = $this->audit->findPaged($effectiveFilters, $page, self::PAGE_SIZE);
  59. return Response::html($this->view->render('audit/index', [
  60. 'title' => 'Audit log',
  61. 'currentUser' => $actor,
  62. 'csrfToken' => SessionGuard::csrfToken(),
  63. 'filters' => $filters,
  64. 'dateErrors' => $dates['errors'],
  65. 'page' => $result['page'],
  66. 'pages' => $result['pages'],
  67. 'pageSize' => $result['pageSize'],
  68. 'total' => $result['total'],
  69. 'rows' => $result['rows'],
  70. 'actions' => $this->audit->distinctActions(),
  71. 'entityTypes' => $this->audit->distinctEntityTypes(),
  72. 'users' => $this->audit->distinctUserEmails(),
  73. ]));
  74. }
  75. /**
  76. * Pure helper: parse the raw `from_date` / `to_date` query inputs,
  77. * drop anything that is not a strict `Y-m-d`, and report which fields
  78. * the user got wrong. The repo concatenates `T00:00:00Z` / `T23:59:59Z`
  79. * onto whatever it gets, so any garbage that reaches it just sorts
  80. * lexically against `occurred_at` and silently hides rows (R01-N12).
  81. *
  82. * @return array{from: string, to: string, errors: array<string,string>}
  83. */
  84. public static function validateDateFilters(string $from, string $to): array
  85. {
  86. $errors = [];
  87. $effFrom = '';
  88. if ($from !== '') {
  89. if (self::isIsoDate($from)) {
  90. $effFrom = $from;
  91. } else {
  92. $errors['from_date'] = 'Use the format YYYY-MM-DD.';
  93. }
  94. }
  95. $effTo = '';
  96. if ($to !== '') {
  97. if (self::isIsoDate($to)) {
  98. $effTo = $to;
  99. } else {
  100. $errors['to_date'] = 'Use the format YYYY-MM-DD.';
  101. }
  102. }
  103. return ['from' => $effFrom, 'to' => $effTo, 'errors' => $errors];
  104. }
  105. private static function isIsoDate(string $s): bool
  106. {
  107. $d = DateTimeImmutable::createFromFormat('Y-m-d', $s);
  108. return $d !== false && $d->format('Y-m-d') === $s;
  109. }
  110. }