* SPDX-License-Identifier: Apache-2.0 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * See the LICENSE file in the project root for the full license text. */ declare(strict_types=1); namespace App\Controllers; use App\Auth\SessionGuard; use App\Http\Request; use App\Http\Response; use App\Http\View; use App\Repositories\AuditRepository; use App\Repositories\UserRepository; use DateTimeImmutable; /** * /audit — admin-only read-only viewer. * * Never writes to the audit log (that goes through AuditLogger), and there * is NO admin action here that truncates or deletes rows. The spec forbids * that. */ final class AuditController { private const PAGE_SIZE = 50; public function __construct( private readonly UserRepository $users, private readonly AuditRepository $audit, private readonly View $view, ) { } public function index(Request $req): Response { $actor = SessionGuard::requireAdmin($this->users); if ($actor instanceof Response) { return $actor; } $rawFrom = $req->queryString('from_date'); $rawTo = $req->queryString('to_date'); $dates = self::validateDateFilters($rawFrom, $rawTo); // Form inputs echo what the user typed (so they can fix a typo); // the repo only sees the values that parsed cleanly as Y-m-d. $filters = [ 'user_email' => $req->queryString('user_email'), 'action' => $req->queryString('action'), 'entity_type' => $req->queryString('entity_type'), 'entity_id' => $req->queryString('entity_id'), 'from_date' => $rawFrom, 'to_date' => $rawTo, ]; $effectiveFilters = $filters; $effectiveFilters['from_date'] = $dates['from']; $effectiveFilters['to_date'] = $dates['to']; $page = (int) ($req->queryString('page') ?: '1'); $result = $this->audit->findPaged($effectiveFilters, $page, self::PAGE_SIZE); return Response::html($this->view->render('audit/index', [ 'title' => 'Audit log', 'currentUser' => $actor, 'csrfToken' => SessionGuard::csrfToken(), 'filters' => $filters, 'dateErrors' => $dates['errors'], 'page' => $result['page'], 'pages' => $result['pages'], 'pageSize' => $result['pageSize'], 'total' => $result['total'], 'rows' => $result['rows'], 'actions' => $this->audit->distinctActions(), 'entityTypes' => $this->audit->distinctEntityTypes(), 'users' => $this->audit->distinctUserEmails(), ])); } /** * Pure helper: parse the raw `from_date` / `to_date` query inputs, * drop anything that is not a strict `Y-m-d`, and report which fields * the user got wrong. The repo concatenates `T00:00:00Z` / `T23:59:59Z` * onto whatever it gets, so any garbage that reaches it just sorts * lexically against `occurred_at` and silently hides rows (R01-N12). * * @return array{from: string, to: string, errors: array} */ public static function validateDateFilters(string $from, string $to): array { $errors = []; $effFrom = ''; if ($from !== '') { if (self::isIsoDate($from)) { $effFrom = $from; } else { $errors['from_date'] = 'Use the format YYYY-MM-DD.'; } } $effTo = ''; if ($to !== '') { if (self::isIsoDate($to)) { $effTo = $to; } else { $errors['to_date'] = 'Use the format YYYY-MM-DD.'; } } return ['from' => $effFrom, 'to' => $effTo, 'errors' => $errors]; } private static function isIsoDate(string $s): bool { $d = DateTimeImmutable::createFromFormat('Y-m-d', $s); return $d !== false && $d->format('Y-m-d') === $s; } }