| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126 |
- <?php
- /*
- * Copyright 2026 Alessandro Chiapparini <sprint_planer_web@chiapparini.org>
- * 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<string,string>}
- */
- 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;
- }
- }
|