Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.19% covered (success)
95.19%
99 / 104
85.71% covered (success)
85.71%
12 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Tracking
95.19% covered (success)
95.19%
99 / 104
85.71% covered (success)
85.71%
12 / 14
32
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
 getInstance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 log
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
6
 initializeSessionId
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getCookieId
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 countBots
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getRemoteAddress
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 isBanned
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 handleSession
80.95% covered (success)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
4.11
 writeTrackingData
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
3
 getBotIgnoreList
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRequestHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createNetwork
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getTrackingDirectory
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * Class for User tracking handling.
5 *
6 * This class handles all operations around creating, saving and getting the secret
7 * for a CurrentUser for two-factor-authentication. It also validates given tokens in
8 * comparison to a given secret and returns a QR-code for transmitting a secret to
9 * the authenticator-app.
10 *
11 * This Source Code Form is subject to the terms of the Mozilla Public License,
12 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
13 * obtain one at http://mozilla.org/MPL/2.0/.
14 *
15 * @package   phpMyFAQ
16 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
17 * @copyright 2024-2026 phpMyFAQ Team
18 * @license   http://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
19 * @link      https://www.phpmyfaq.de
20 * @since     2024-10-29
21 */
22
23declare(strict_types=1);
24
25namespace phpMyFAQ\User;
26
27use Closure;
28use phpMyFAQ\Configuration;
29use phpMyFAQ\Core\Exception;
30use phpMyFAQ\Database;
31use phpMyFAQ\Enums\SessionActionType;
32use phpMyFAQ\Filter;
33use phpMyFAQ\Network;
34use phpMyFAQ\Strings;
35use Symfony\Component\HttpFoundation\HeaderBag;
36use Symfony\Component\HttpFoundation\IpUtils;
37use Symfony\Component\HttpFoundation\Request;
38
39class Tracking
40{
41    private static ?Tracking $tracking = null;
42
43    private ?int $currentSessionId = null;
44
45    private function __construct(
46        private readonly Configuration $configuration,
47        private readonly Request $request,
48        private readonly UserSession $userSession,
49        private readonly ?Closure $networkFactory = null,
50        private readonly ?string $trackingDirectory = null,
51    ) {
52    }
53
54    public static function getInstance(
55        Configuration $configuration,
56        Request $request,
57        UserSession $userSession,
58        ?Closure $networkFactory = null,
59        ?string $trackingDirectory = null,
60    ): Tracking {
61        if (!self::$tracking instanceof Tracking) {
62            self::$tracking = new self($configuration, $request, $userSession, $networkFactory, $trackingDirectory);
63        }
64
65        return self::$tracking;
66    }
67
68    /**
69     * @throws Exception
70     */
71    public function log(string $action, int|string|null $data = null): bool
72    {
73        if (!$this->configuration->get(item: 'main.enableUserTracking')) {
74            return false;
75        }
76
77        $this->initializeSessionId();
78        $cookieId = $this->getCookieId();
79
80        if (!is_null($cookieId)) {
81            $this->userSession->setCurrentSessionId($cookieId);
82        }
83
84        if ($action === SessionActionType::OLD_SESSION->value) {
85            $this->userSession->setCurrentSessionId(0);
86        }
87
88        $bots = $this->countBots();
89        $remoteAddress = $this->getRemoteAddress();
90        $banned = $this->isBanned($remoteAddress);
91
92        if (0 === $bots && false === $banned) {
93            $this->handleSession($cookieId, $remoteAddress, $action, $data);
94        }
95
96        return true;
97    }
98
99    private function initializeSessionId(): void
100    {
101        $sessionId = Filter::filterVar(
102            $this->request->query->get(UserSession::KEY_NAME_SESSION_ID),
103            FILTER_VALIDATE_INT,
104        );
105
106        if ($sessionId !== null) {
107            $this->currentSessionId = $sessionId;
108        }
109    }
110
111    private function getCookieId(): ?int
112    {
113        return Filter::filterVar($this->request->query->get(UserSession::COOKIE_NAME_SESSION_ID), FILTER_VALIDATE_INT);
114    }
115
116    private function countBots(): int
117    {
118        $bots = 0;
119        foreach ($this->getBotIgnoreList() as $bot) {
120            if (!Strings::strstr($this->getRequestHeaders()->get('user-agent') ?? '1', $bot)) {
121                continue;
122            }
123
124            ++$bots;
125        }
126
127        return $bots;
128    }
129
130    public function getRemoteAddress(): string
131    {
132        $remoteAddress = $this->request->getClientIp();
133        $localAddresses = ['127.0.0.1', '::1'];
134
135        if (
136            in_array($remoteAddress, $localAddresses, strict: true)
137            && $this->getRequestHeaders()->has('X-Forwarded-For')
138        ) {
139            $remoteAddress = $this->getRequestHeaders()->get('X-Forwarded-For');
140        }
141
142        return preg_replace(pattern: '([^0-9a-z:.]+)i', replacement: '', subject: (string) $remoteAddress) ?? '';
143    }
144
145    private function isBanned(string $remoteAddress): bool
146    {
147        $network = $this->createNetwork();
148        return $network->isBanned(IpUtils::anonymize($remoteAddress));
149    }
150
151    /**
152     * @throws Exception
153     */
154    private function handleSession(?int $cookieId, string $remoteAddress, string $action, int|string|null $data): void
155    {
156        if ($this->currentSessionId === null) {
157            $this->currentSessionId = $this->configuration->getDb()->nextId(
158                Database::getTablePrefix() . 'faqsessions',
159                'sid',
160            );
161            $this->userSession->setCurrentSessionId($this->currentSessionId);
162
163            if (!is_null($cookieId) && !$cookieId !== $this->userSession->getCurrentSessionId()) {
164                $this->userSession->setCookie(
165                    UserSession::COOKIE_NAME_SESSION_ID,
166                    $this->userSession->getCurrentSessionId(),
167                );
168            }
169
170            $query = sprintf(
171                "INSERT INTO %sfaqsessions (sid, user_id, ip, time) VALUES (%d, %d, '%s', %d)",
172                Database::getTablePrefix(),
173                $this->userSession->getCurrentSessionId(),
174                CurrentUser::getCurrentUser($this->configuration)->getUserId(),
175                $remoteAddress,
176                (int) $this->request->server->get('REQUEST_TIME'),
177            );
178
179            $this->configuration->getDb()->query($query);
180        }
181
182        $this->writeTrackingData($action, $data, $remoteAddress);
183    }
184
185    private function writeTrackingData(string $action, int|string|null $data, string $remoteAddress): void
186    {
187        $redactor = new TrackingDataRedactor();
188
189        $data =
190            (string) $this->userSession->getCurrentSessionId()
191            . ';'
192            . str_replace(search: ';', replace: ',', subject: $action)
193            . ';'
194            . (string) $data
195            . ';'
196            . $remoteAddress
197            . ';'
198            . str_replace(
199                search: ';',
200                replace: ',',
201                subject: $redactor->redactQueryString((string) ($this->request->server->get('QUERY_STRING') ?? '')),
202            )
203            . ';'
204            . str_replace(
205                search: ';',
206                replace: ',',
207                subject: $redactor->redactUrl((string) ($this->request->server->get('HTTP_REFERER') ?? '')),
208            )
209            . ';'
210            . str_replace(
211                search: ';',
212                replace: ',',
213                subject: urldecode((string) $this->request->server->get('HTTP_USER_AGENT')),
214            )
215            . ';'
216            . (int) $this->request->server->get('REQUEST_TIME')
217            . ";\n";
218
219        $file = $this->getTrackingDirectory() . '/tracking' . date(format: 'dmY');
220
221        if (!is_file($file)) {
222            touch($file);
223        }
224
225        if (!is_writable($file)) {
226            $this->configuration->getLogger()->error('Cannot write to ' . $file);
227        }
228
229        file_put_contents($file, $data, FILE_APPEND | LOCK_EX);
230    }
231
232    /**
233     * Returns the botIgnoreList as an array.
234     * @return array<string>
235     */
236    private function getBotIgnoreList(): array
237    {
238        return explode(',', (string) $this->configuration->get(item: 'main.botIgnoreList'));
239    }
240
241    private function getRequestHeaders(): HeaderBag
242    {
243        return $this->request->headers;
244    }
245
246    private function createNetwork(): Network
247    {
248        if ($this->networkFactory instanceof Closure) {
249            $network = ($this->networkFactory)();
250            if ($network instanceof Network) {
251                return $network;
252            }
253        }
254
255        return new Network($this->configuration);
256    }
257
258    private function getTrackingDirectory(): string
259    {
260        return $this->trackingDirectory ?? (string) PMF_ROOT_DIR . '/content/core/data';
261    }
262}