Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.65% covered (success)
95.65%
44 / 46
85.71% covered (success)
85.71%
6 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
AdminLog
95.65% covered (success)
95.65%
44 / 46
85.71% covered (success)
85.71%
6 / 7
15
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getNumberOfEntries
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAll
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 log
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 verifyChainIntegrity
94.29% covered (success)
94.29%
33 / 35
0.00% covered (danger)
0.00%
0 / 1
8.01
 calculateHash
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * The main Logging class.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2006-2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2006-08-15
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Administration;
21
22use phpMyFAQ\Configuration;
23use phpMyFAQ\Entity\AdminLog as AdminLogEntity;
24use phpMyFAQ\User;
25use Symfony\Component\HttpFoundation\Request;
26
27/**
28 * Class Logging
29 *
30 * @package phpMyFAQ
31 */
32readonly class AdminLog
33{
34    private AdminLogRepository $adminLogRepository;
35
36    /**
37     * Constructor.
38     */
39    public function __construct(
40        private Configuration $configuration,
41    ) {
42        $this->adminLogRepository = new AdminLogRepository($this->configuration);
43    }
44
45    /**
46     * Returns the number of entries.
47     */
48    public function getNumberOfEntries(): int
49    {
50        return $this->adminLogRepository->getNumberOfEntries();
51    }
52
53    /**
54     * Returns all data from the admin log.
55     * @return AdminLogEntity[]
56     */
57    public function getAll(): array
58    {
59        return $this->adminLogRepository->getAll();
60    }
61
62    /**
63     * Adds a new admin log entry.
64     *
65     * @param User   $user    User object
66     * @param string $logText Logged string
67     */
68    public function log(User $user, string $logText = ''): bool
69    {
70        if (!$this->configuration->get(item: 'main.enableAdminLog')) {
71            return false;
72        }
73
74        $request = Request::createFromGlobals();
75
76        // Get the hash of the last log entry for chaining
77        $previousHash = $this->adminLogRepository->getLastHash();
78
79        return $this->adminLogRepository->add($user, $logText, $request, $previousHash);
80    }
81
82    /**
83     * Deletes logging data older than 30 days.
84     */
85    public function delete(): bool
86    {
87        $timestamp = (int) Request::createFromGlobals()->server->get(key: 'REQUEST_TIME') - (30 * 86_400);
88        return $this->adminLogRepository->deleteOlderThan($timestamp);
89    }
90
91    /**
92     * Verifies the integrity of the entire admin log chain.
93     * @return array{valid: bool, errors: array<int, string>, total: int, verified: int}
94     */
95    public function verifyChainIntegrity(): array
96    {
97        $logs = $this->getAll();
98        $errors = [];
99        $verified = 0;
100        $total = count($logs);
101
102        if ($total === 0) {
103            return [
104                'valid' => true,
105                'errors' => [],
106                'total' => 0,
107                'verified' => 0,
108            ];
109        }
110
111        $previousHash = null;
112
113        foreach ($logs as $log) {
114            // Verify the hash matches the stored hash
115            if (!$log->verifyIntegrity()) {
116                $errors[] = sprintf('Log ID %d: Hash verification failed - data has been tampered', $log->getId());
117                continue;
118            }
119
120            // Verify the chain (previous hash matches)
121            if ($previousHash !== null && $log->getPreviousHash() !== $previousHash) {
122                $errors[] = sprintf(
123                    'Log ID %d: Chain broken - previous hash mismatch (expected: %s, got: %s)',
124                    $log->getId(),
125                    substr(string: $previousHash, offset: 0, length: 8) . '...',
126                    substr(string: $log->getPreviousHash() ?? 'NULL', offset: 0, length: 8) . '...',
127                );
128                continue;
129            }
130
131            // The first entry should have null previous hash
132            if ($previousHash === null && $log->getPreviousHash() !== null) {
133                $errors[] = sprintf('Log ID %d: First entry should have null previous hash', $log->getId());
134                continue;
135            }
136
137            $verified++;
138            $previousHash = $log->getHash();
139        }
140
141        return [
142            'valid' => $errors === [],
143            'errors' => $errors,
144            'total' => $total,
145            'verified' => $verified,
146        ];
147    }
148
149    /**
150     * Calculates hash for a single log entry (for migration or manual verification).
151     */
152    public function calculateHash(AdminLogEntity $log): string
153    {
154        return $log->calculateHash();
155    }
156}